← 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();