← back to Jill Website

src/routes/activities.ts

491 lines

import { Router, Request, Response } from 'express';
import { ActivityModel, Activity, BulkImportResult } from '../models/Activity';
import { googleReviewsService } from '../services/googleReviewsService';
import { requireAuth } from '../middleware/auth';
import multer from 'multer';

const router = Router();

// Configure multer for file uploads (memory storage)
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 } // 5MB limit
});

/**
 * Create activities router with optional dependency injection for testing
 */
export function createActivitiesRouter(
  activityModel?: ActivityModel
): Router {
  const model = activityModel || new ActivityModel();
  const router = Router();

  /**
   * POST /activities
   * Create a new activity
   */
  router.post('/activities', requireAuth, async (req: Request, res: Response) => {
    try {
      if (!req.body) {
        return res.status(400).json({
          success: false,
          error: 'Validation Error',
          message: 'Request body is required'
        });
      }

      const activity = await model.create(req.body);

      res.status(201).json({
        success: true,
        data: { id: activity.id, activity },
        message: 'Activity created successfully'
      });
    } catch (error) {
      console.error('Error creating activity:', error);
      res.status(400).json({
        success: false,
        error: 'Validation Error',
        message: error instanceof Error ? error.message : 'Unknown error'
      });
    }
  });

  /**
   * GET /activities
   * Get all activities with pagination
   */
  router.get('/activities', async (req: Request, res: Response) => {
    try {
      const limit = parseInt(req.query.limit as string || '100');
      const offset = parseInt(req.query.offset as string || '0');

      const activities = await model.findAll(limit, offset);

      res.json({
        success: true,
        data: activities,
        count: activities.length
      });
    } catch (error) {
      console.error('Error fetching activities:', error);
      res.status(500).json({
        success: false,
        error: 'Server Error',
        message: 'Failed to fetch activities'
      });
    }
  });

  /**
   * GET /activities/category/:category
   * Get activities by category
   */
  router.get('/activities/category/:category', async (req: Request, res: Response) => {
    try {
      const categoryParam = Array.isArray(req.params.category) ? req.params.category[0] : req.params.category;
      const limit = parseInt(req.query.limit as string || '100');
      const offset = parseInt(req.query.offset as string || '0');

      const activities = await model.findByCategory(categoryParam, limit, offset);

      res.json({
        success: true,
        data: activities,
        count: activities.length
      });
    } catch (error) {
      console.error('Error fetching activities by category:', error);
      res.status(500).json({
        success: false,
        error: 'Server Error',
        message: 'Failed to fetch activities'
      });
    }
  });

  /**
   * GET /activities/:id
   * Get a specific activity by ID
   */
  router.get('/activities/:id', async (req: Request, res: Response) => {
    try {
      const idParam = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
      const id = parseInt(idParam, 10);

      if (isNaN(id) || !Number.isInteger(parseFloat(idParam))) {
        return res.status(400).json({
          success: false,
          error: 'Validation Error',
          message: 'Invalid activity ID'
        });
      }

      const activity = await model.findById(id);

      if (!activity) {
        return res.status(404).json({
          success: false,
          error: 'Not Found',
          message: 'Activity not found'
        });
      }

      res.json({
        success: true,
        data: activity
      });
    } catch (error) {
      console.error('Error fetching activity:', error);
      res.status(500).json({
        success: false,
        error: 'Server Error',
        message: 'Failed to fetch activity'
      });
    }
  });

  /**
   * PUT /activities/:id
   * Update an activity
   */
  router.put('/activities/:id', requireAuth, async (req: Request, res: Response) => {
    try {
      if (!req.body) {
        return res.status(400).json({
          success: false,
          error: 'Validation Error',
          message: 'Request body is required'
        });
      }

      const idParam = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
      const id = parseInt(idParam, 10);

      if (isNaN(id)) {
        return res.status(400).json({
          success: false,
          error: 'Validation Error',
          message: 'Invalid activity ID'
        });
      }

      const activity = await model.update(id, req.body);

      if (!activity) {
        return res.status(404).json({
          success: false,
          error: 'Not Found',
          message: 'Activity not found'
        });
      }

      res.json({
        success: true,
        data: activity,
        message: 'Activity updated successfully'
      });
    } catch (error) {
      console.error('Error updating activity:', error);
      res.status(400).json({
        success: false,
        error: 'Validation Error',
        message: error instanceof Error ? error.message : 'Unknown error'
      });
    }
  });

  /**
   * DELETE /activities/:id
   * Delete an activity
   */
  router.delete('/activities/:id', requireAuth, async (req: Request, res: Response) => {
    try {
      const idParam = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
      const id = parseInt(idParam, 10);

      if (isNaN(id)) {
        return res.status(400).json({
          success: false,
          error: 'Validation Error',
          message: 'Invalid activity ID'
        });
      }

      const deleted = await model.delete(id);

      if (!deleted) {
        return res.status(404).json({
          success: false,
          error: 'Not Found',
          message: 'Activity not found'
        });
      }

      res.json({
        success: true,
        message: 'Activity deleted successfully'
      });
    } catch (error) {
      console.error('Error deleting activity:', error);
      res.status(500).json({
        success: false,
        error: 'Server Error',
        message: 'Failed to delete activity'
      });
    }
  });

  /**
   * POST /activities/:id/reviews/refresh
   * Refresh review data for an activity from Google Reviews API
   */
  router.post('/activities/:id/reviews/refresh', requireAuth, async (req: Request, res: Response) => {
    try {
      const idParam = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
      const id = parseInt(idParam, 10);

      if (isNaN(id)) {
        return res.status(400).json({
          success: false,
          error: 'Validation Error',
          message: 'Invalid activity ID'
        });
      }

      const activity = await model.findById(id);

      if (!activity) {
        return res.status(404).json({
          success: false,
          error: 'Not Found',
          message: 'Activity not found'
        });
      }

      if (!activity.businessId) {
        return res.status(400).json({
          success: false,
          error: 'Validation Error',
          message: 'Activity does not have a Google Place ID (businessId)'
        });
      }

      // Fetch review information from Google
      const reviewInfo = await googleReviewsService.getReviewInfo(activity.businessId);

      if (!reviewInfo) {
        return res.status(404).json({
          success: false,
          error: 'Not Found',
          message: 'Could not find review information for this business'
        });
      }

      // Update activity with review data
      const updatedActivity = await model.update(id, {
        reviewLink: reviewInfo.reviewLink,
        rating: reviewInfo.rating
      });

      res.json({
        success: true,
        data: {
          activity: updatedActivity,
          reviewInfo: {
            rating: reviewInfo.rating,
            totalReviews: reviewInfo.totalReviews
          }
        },
        message: 'Review data refreshed successfully'
      });
    } catch (error) {
      console.error('Error refreshing review data:', error);
      res.status(500).json({
        success: false,
        error: 'Server Error',
        message: error instanceof Error ? error.message : 'Failed to refresh review data'
      });
    }
  });

  /**
   * POST /activities/bulk-import
   * Bulk import activities from JSON array or file upload
   * Accepts:
   * - JSON body with activities array
   * - CSV file upload
   * - JSON file upload
   */
  router.post('/activities/bulk-import', requireAuth, upload.single('file'), async (req: Request, res: Response) => {
    try {
      let activities: Omit<Activity, 'id' | 'created_at' | 'updated_at'>[] = [];

      // Check if file was uploaded
      if (req.file) {
        const fileContent = req.file.buffer.toString('utf-8');
        const fileType = req.file.mimetype;

        if (fileType === 'application/json' || req.file.originalname.endsWith('.json')) {
          // Parse JSON file
          try {
            const parsed = JSON.parse(fileContent);
            activities = Array.isArray(parsed) ? parsed : [parsed];
          } catch (error) {
            return res.status(400).json({
              success: false,
              error: 'Validation Error',
              message: 'Invalid JSON format in uploaded file'
            });
          }
        } else if (fileType === 'text/csv' || req.file.originalname.endsWith('.csv')) {
          // Parse CSV file
          activities = parseCSV(fileContent);
        } else {
          return res.status(400).json({
            success: false,
            error: 'Validation Error',
            message: 'Unsupported file type. Please upload JSON or CSV file.'
          });
        }
      } else if (req.body && Array.isArray(req.body)) {
        // Direct JSON array in body
        activities = req.body;
      } else if (req.body && req.body.activities && Array.isArray(req.body.activities)) {
        // JSON object with activities array
        activities = req.body.activities;
      } else {
        return res.status(400).json({
          success: false,
          error: 'Validation Error',
          message: 'No activities data provided. Send JSON array or upload CSV/JSON file.'
        });
      }

      if (activities.length === 0) {
        return res.status(400).json({
          success: false,
          error: 'Validation Error',
          message: 'No activities to import'
        });
      }

      // Perform bulk import
      const result: BulkImportResult = await model.bulkCreate(activities);

      res.status(201).json({
        success: true,
        data: result,
        message: `Bulk import completed: ${result.successCount} succeeded, ${result.failureCount} failed`
      });
    } catch (error) {
      console.error('Error during bulk import:', error);
      res.status(500).json({
        success: false,
        error: 'Server Error',
        message: error instanceof Error ? error.message : 'Failed to process bulk import'
      });
    }
  });

  return router;
}

/**
 * Parse CSV content into activity objects
 */
function parseCSV(csvContent: string): Omit<Activity, 'id' | 'created_at' | 'updated_at'>[] {
  const lines = csvContent.trim().split('\n');

  if (lines.length < 2) {
    throw new Error('CSV file must contain header row and at least one data row');
  }

  // Parse header
  const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));

  // Parse data rows
  const activities: Omit<Activity, 'id' | 'created_at' | 'updated_at'>[] = [];

  for (let i = 1; i < lines.length; i++) {
    const line = lines[i].trim();
    if (!line) continue; // Skip empty lines

    const values = parseCSVLine(line);
    const activity: any = {};

    headers.forEach((header, index) => {
      const value = values[index]?.trim();

      // Map CSV headers to activity fields (case-insensitive)
      const fieldMap: { [key: string]: string } = {
        'name': 'name',
        'category': 'category',
        'description': 'description',
        'phone': 'phone',
        'website': 'website',
        'mapslink': 'mapsLink',
        'maps_link': 'mapsLink',
        'maps link': 'mapsLink',
        'reviewlink': 'reviewLink',
        'review_link': 'reviewLink',
        'review link': 'reviewLink',
        'businessid': 'businessId',
        'business_id': 'businessId',
        'business id': 'businessId',
        'rating': 'rating',
        'contactstatus': 'contactStatus',
        'contact_status': 'contactStatus',
        'contact status': 'contactStatus'
      };

      const normalizedHeader = header.toLowerCase().replace(/[^a-z0-9]/g, '');
      const fieldName = fieldMap[normalizedHeader] || header;

      if (value && value !== '') {
        // Convert rating to number
        if (fieldName === 'rating') {
          activity[fieldName] = parseFloat(value);
        } else {
          activity[fieldName] = value;
        }
      }
    });

    if (activity.name && activity.category) {
      activities.push(activity);
    }
  }

  return activities;
}

/**
 * Parse a single CSV line, handling quoted values with commas
 */
function parseCSVLine(line: string): string[] {
  const values: string[] = [];
  let currentValue = '';
  let insideQuotes = false;

  for (let i = 0; i < line.length; i++) {
    const char = line[i];

    if (char === '"') {
      insideQuotes = !insideQuotes;
    } else if (char === ',' && !insideQuotes) {
      values.push(currentValue);
      currentValue = '';
    } else {
      currentValue += char;
    }
  }

  values.push(currentValue); // Push last value
  return values.map(v => v.replace(/^"|"$/g, '')); // Remove surrounding quotes
}

// Export default instance for production use
export default createActivitiesRouter();