← back to Jill Website
src/models/Activity.ts
318 lines
import { Pool, QueryResult } from 'pg';
import { getPool } from '../config/database';
import { validatePhoneNumber, formatPhoneNumber } from '../utils/phoneFormatter';
export interface Activity {
id?: number;
name: string;
category: string;
description?: string;
phone?: string;
website?: string;
mapsLink?: string;
reviewLink?: string;
businessId?: string;
rating?: number;
contactStatus?: 'available' | 'unavailable';
created_at?: Date;
updated_at?: Date;
}
export interface ActivityValidationError {
field: string;
message: string;
}
export class ActivityModel {
private pool: Pool;
constructor() {
this.pool = getPool();
}
/**
* Validate activity data
*/
private validate(activity: Partial<Activity>): ActivityValidationError[] {
const errors: ActivityValidationError[] = [];
if (!activity.name || activity.name.trim().length === 0) {
errors.push({ field: 'name', message: 'Activity name is required' });
}
if (!activity.category || activity.category.trim().length === 0) {
errors.push({ field: 'category', message: 'Activity category is required' });
}
// Validate URL format for website if provided
if (activity.website && !this.isValidUrl(activity.website)) {
errors.push({ field: 'website', message: 'Website must be a valid URL' });
}
// Validate URL format for mapsLink if provided
if (activity.mapsLink && !this.isValidUrl(activity.mapsLink)) {
errors.push({ field: 'mapsLink', message: 'Maps link must be a valid URL' });
}
// Validate URL format for reviewLink if provided
if (activity.reviewLink && !this.isValidUrl(activity.reviewLink)) {
errors.push({ field: 'reviewLink', message: 'Review link must be a valid URL' });
}
// Validate contactStatus if provided
if (activity.contactStatus && !['available', 'unavailable'].includes(activity.contactStatus)) {
errors.push({ field: 'contactStatus', message: 'Contact status must be either "available" or "unavailable"' });
}
// Validate rating if provided
if (activity.rating !== undefined && activity.rating !== null) {
if (typeof activity.rating !== 'number' || activity.rating < 0 || activity.rating > 5) {
errors.push({ field: 'rating', message: 'Rating must be a number between 0 and 5' });
}
}
// Validate phone if provided
if (activity.phone && activity.phone.trim().length > 0) {
const phoneValidation = validatePhoneNumber(activity.phone);
if (!phoneValidation.isValid) {
errors.push({ field: 'phone', message: phoneValidation.error || 'Invalid phone number' });
}
}
return errors;
}
/**
* Validate URL format
*/
private isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* Create a new activity
*/
async create(activity: Omit<Activity, 'id' | 'created_at' | 'updated_at'>): Promise<Activity> {
const errors = this.validate(activity);
if (errors.length > 0) {
throw new Error(`Validation failed: ${errors.map(e => `${e.field}: ${e.message}`).join(', ')}`);
}
try {
const query = `
INSERT INTO activities (name, category, description, phone, website, mapsLink, reviewLink, businessId, rating, contactStatus)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
`;
const values = [
activity.name.trim(),
activity.category.trim(),
activity.description?.trim() || null,
activity.phone ? formatPhoneNumber(activity.phone.trim()) : null,
activity.website?.trim() || null,
activity.mapsLink?.trim() || null,
activity.reviewLink?.trim() || null,
activity.businessId?.trim() || null,
activity.rating || null,
activity.contactStatus || null
];
const result: QueryResult<Activity> = await this.pool.query(query, values);
return result.rows[0];
} catch (error) {
console.error('Error creating activity:', error);
throw new Error(`Failed to create activity: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get activity by ID
*/
async findById(id: number): Promise<Activity | null> {
try {
const query = 'SELECT * FROM activities WHERE id = $1';
const result: QueryResult<Activity> = await this.pool.query(query, [id]);
if (result.rows.length === 0) {
return null;
}
return result.rows[0];
} catch (error) {
console.error('Error finding activity:', error);
throw new Error(`Failed to find activity: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get all activities
*/
async findAll(limit: number = 100, offset: number = 0): Promise<Activity[]> {
try {
const query = 'SELECT * FROM activities ORDER BY created_at DESC LIMIT $1 OFFSET $2';
const result: QueryResult<Activity> = await this.pool.query(query, [limit, offset]);
return result.rows;
} catch (error) {
console.error('Error finding activities:', error);
throw new Error(`Failed to find activities: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get activities by category
*/
async findByCategory(category: string, limit: number = 100, offset: number = 0): Promise<Activity[]> {
try {
const query = 'SELECT * FROM activities WHERE category = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3';
const result: QueryResult<Activity> = await this.pool.query(query, [category, limit, offset]);
return result.rows;
} catch (error) {
console.error('Error finding activities by category:', error);
throw new Error(`Failed to find activities by category: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Update activity
*/
async update(id: number, activity: Partial<Activity>): Promise<Activity | null> {
const errors = this.validate(activity);
if (errors.length > 0) {
throw new Error(`Validation failed: ${errors.map(e => `${e.field}: ${e.message}`).join(', ')}`);
}
try {
const query = `
UPDATE activities
SET name = COALESCE($1, name),
category = COALESCE($2, category),
description = COALESCE($3, description),
phone = COALESCE($4, phone),
website = COALESCE($5, website),
mapsLink = COALESCE($6, mapsLink),
reviewLink = COALESCE($7, reviewLink),
businessId = COALESCE($8, businessId),
rating = COALESCE($9, rating),
contactStatus = COALESCE($10, contactStatus),
updated_at = NOW()
WHERE id = $11
RETURNING *
`;
const values = [
activity.name?.trim() || null,
activity.category?.trim() || null,
activity.description?.trim() || null,
activity.phone ? formatPhoneNumber(activity.phone.trim()) : null,
activity.website?.trim() || null,
activity.mapsLink?.trim() || null,
activity.reviewLink?.trim() || null,
activity.businessId?.trim() || null,
activity.rating || null,
activity.contactStatus || null,
id
];
const result: QueryResult<Activity> = await this.pool.query(query, values);
if (result.rows.length === 0) {
return null;
}
return result.rows[0];
} catch (error) {
console.error('Error updating activity:', error);
throw new Error(`Failed to update activity: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Delete activity
*/
async delete(id: number): Promise<boolean> {
try {
const query = 'DELETE FROM activities WHERE id = $1';
const result = await this.pool.query(query, [id]);
return result.rowCount !== null && result.rowCount > 0;
} catch (error) {
console.error('Error deleting activity:', error);
throw new Error(`Failed to delete activity: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Bulk create activities
* Returns results with successes and failures
*/
async bulkCreate(activities: Omit<Activity, 'id' | 'created_at' | 'updated_at'>[]): Promise<BulkImportResult> {
const results: BulkImportResult = {
totalCount: activities.length,
successCount: 0,
failureCount: 0,
successes: [],
failures: []
};
for (let i = 0; i < activities.length; i++) {
const activity = activities[i];
try {
// Validate the activity
const errors = this.validate(activity);
if (errors.length > 0) {
results.failures.push({
index: i,
activity,
error: `Validation failed: ${errors.map(e => `${e.field}: ${e.message}`).join(', ')}`
});
results.failureCount++;
continue;
}
// Create the activity
const created = await this.create(activity);
results.successes.push({
index: i,
activity: created
});
results.successCount++;
} catch (error) {
results.failures.push({
index: i,
activity,
error: error instanceof Error ? error.message : 'Unknown error'
});
results.failureCount++;
}
}
return results;
}
}
export interface BulkImportResult {
totalCount: number;
successCount: number;
failureCount: number;
successes: Array<{
index: number;
activity: Activity;
}>;
failures: Array<{
index: number;
activity: Partial<Activity>;
error: string;
}>;
}