From 25e37cde663e9ee1508ad137dedf1a32b2d4ec6a Mon Sep 17 00:00:00 2001 From: Joshua Tuddenham Date: Sat, 30 Nov 2024 16:07:02 +0000 Subject: [PATCH 1/7] feat: added util function to update slot numbers for existing rows in itinerary_days --- package.json | 3 +- src/db/tables/itinerary_days.ts | 9 +++++- src/types/db-types.ts | 17 ++++++----- src/utils/slot-number.ts | 50 +++++++++++++++++++++++++++++++++ src/utils/update-slots.ts | 14 +++++++++ 5 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 src/utils/slot-number.ts create mode 100644 src/utils/update-slots.ts diff --git a/package.json b/package.json index 6afff26..2e4f44a 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "test": "cross-env NODE_ENV=test jest --runInBand", "test:unit": "cross-env NODE_ENV=test jest \"tests/unit/.*\\.test\\.ts$\" --runInBand", "test:integration": "cross-env NODE_ENV=test jest \"tests/integration/.*\\.test\\.ts$\" --runInBand", - "test:watch": "cross-env NODE_ENV=test jest --watch --runInBand" + "test:watch": "cross-env NODE_ENV=test jest --watch --runInBand", + "update-slots": "ts-node src/utils/update-slots.ts " }, "author": "", "license": "ISC", diff --git a/src/db/tables/itinerary_days.ts b/src/db/tables/itinerary_days.ts index 08defa6..a71e433 100644 --- a/src/db/tables/itinerary_days.ts +++ b/src/db/tables/itinerary_days.ts @@ -1,4 +1,10 @@ -import { pgTable, bigint, timestamp, serial } from 'drizzle-orm/pg-core'; +import { + pgTable, + bigint, + timestamp, + serial, + integer, +} from 'drizzle-orm/pg-core'; export const itineraryDays = pgTable('itinerary_days', { dayId: serial('day_id').primaryKey(), @@ -8,4 +14,5 @@ export const itineraryDays = pgTable('itinerary_days', { tripId: bigint('trip_id', { mode: 'number' }), dayNumber: bigint('day_number', { mode: 'number' }), activityId: bigint('activity_id', { mode: 'number' }), + slotNumber: integer('slot_number'), }); diff --git a/src/types/db-types.ts b/src/types/db-types.ts index edd6e88..2102c8e 100644 --- a/src/types/db-types.ts +++ b/src/types/db-types.ts @@ -1,13 +1,12 @@ import { Destination } from './destination-type'; import { BestTime, Category, Difficulty } from './trip-types'; -// The shape of each row returned by the Drizzle query export interface DBActivity { activityId: number; - activityName: string | null; // Allow null - description: string | null; // Allow null - location: string | null; // Allow null - price: string | null; // Allow null + activityName: string | null; + description: string | null; + location: string | null; + price: string | null; latitude: number | null; longitude: number | null; duration: string | null; @@ -28,10 +27,10 @@ export interface DBDestination { export interface TripDBRow { tripId: number; - destinationId: number | null; // Allow null - startDate: Date | null; // Allow null - numDays: number | null; // Allow null - itineraryDays: number | null; // Allow null + destinationId: number | null; + startDate: Date | null; + numDays: number | null; + itineraryDays: number | null; status: string; title: string | null; description: string | null; diff --git a/src/utils/slot-number.ts b/src/utils/slot-number.ts new file mode 100644 index 0000000..56b494e --- /dev/null +++ b/src/utils/slot-number.ts @@ -0,0 +1,50 @@ +import { and, eq, sql } from 'drizzle-orm'; +import { executeDbOperation } from './db-utils'; +import { db, itineraryDays } from '../db'; +import { logger } from './logger'; + +export const updateExistingActivitiesWithSlots = async () => { + return executeDbOperation(async () => { + // First get all trip/day combinations that have activities + const tripDays = await db + .select({ + tripId: itineraryDays.tripId, + dayNumber: itineraryDays.dayNumber, + }) + .from(itineraryDays) + .where( + sql`${itineraryDays.tripId} IS NOT NULL AND ${itineraryDays.dayNumber} IS NOT NULL`, + ) + .groupBy(itineraryDays.tripId, itineraryDays.dayNumber); + + for (const { tripId, dayNumber } of tripDays) { + if (tripId === null || dayNumber === null) continue; + + // Get activities for this day ordered by creation time + const dayActivities = await db + .select({ + dayId: itineraryDays.dayId, + }) + .from(itineraryDays) + .where( + and( + sql`${itineraryDays.tripId} = ${tripId}`, + sql`${itineraryDays.dayNumber} = ${dayNumber}`, + ), + ) + .orderBy(itineraryDays.createdAt); + + // Update each activity with its slot number (1-based index) + const updates = dayActivities.map((activity, index) => { + return db + .update(itineraryDays) + .set({ slotNumber: index + 1 }) + .where(eq(itineraryDays.dayId, activity.dayId)); + }); + + await Promise.all(updates); + } + + logger.info('Successfully updated all activities with slot numbers'); + }, 'Error updating activities with slot numbers'); +}; diff --git a/src/utils/update-slots.ts b/src/utils/update-slots.ts new file mode 100644 index 0000000..bde2ac3 --- /dev/null +++ b/src/utils/update-slots.ts @@ -0,0 +1,14 @@ +import { updateExistingActivitiesWithSlots } from './slot-number'; + +const main = async () => { + try { + await updateExistingActivitiesWithSlots(); + console.log('Successfully updated activity slots'); + process.exit(0); + } catch (error) { + console.error('Error updating slots:', error); + process.exit(1); + } +}; + +main(); From f0e71d40f7b8d0cb478436577639c6efa07cbab5 Mon Sep 17 00:00:00 2001 From: Joshua Tuddenham Date: Sat, 30 Nov 2024 16:23:32 +0000 Subject: [PATCH 2/7] feat: added reorder activities controller and route --- .../activities/reorder-activities.ts | 66 ++++++++++ src/routes/routes.ts | 6 + .../activity-service/db-operations.ts | 123 ++++++++++++++++++ src/services/activity-service/index.ts | 1 + src/types/trip-types.ts | 7 + 5 files changed, 203 insertions(+) create mode 100644 src/controllers/activities/reorder-activities.ts create mode 100644 src/services/activity-service/db-operations.ts create mode 100644 src/services/activity-service/index.ts diff --git a/src/controllers/activities/reorder-activities.ts b/src/controllers/activities/reorder-activities.ts new file mode 100644 index 0000000..c56e741 --- /dev/null +++ b/src/controllers/activities/reorder-activities.ts @@ -0,0 +1,66 @@ +import { Request, Response } from 'express'; +import { logger } from '../../utils/logger'; +import { reorderActivities } from '@/services/activity-service/'; + +export const handleReorderActivities = async (req: Request, res: Response) => { + try { + const tripId = parseInt(req.params.tripId, 10); + const dayNumber = parseInt(req.params.dayNumber, 10); + const { updates } = req.body; + + // Basic validation + if (isNaN(tripId) || isNaN(dayNumber)) { + return res.status(400).json({ + message: 'Invalid trip ID or day number', + }); + } + + if (!Array.isArray(updates) || updates.length === 0 || updates.length > 3) { + return res.status(400).json({ + message: 'Updates must be an array of 1-3 activities', + }); + } + + // Validate each update object + const isValidUpdate = updates.every( + (update) => + typeof update.activityId === 'number' && + typeof update.slotNumber === 'number' && + update.slotNumber >= 1 && + update.slotNumber <= 3, + ); + + if (!isValidUpdate) { + return res.status(400).json({ + message: + 'Invalid update format. Each update must have activityId and slotNumber (1-3)', + }); + } + + // Check for duplicate slots + const slots = new Set(updates.map((u) => u.slotNumber)); + if (slots.size !== updates.length) { + return res.status(400).json({ + message: 'Duplicate slot numbers are not allowed', + }); + } + + const updatedActivities = await reorderActivities( + tripId, + dayNumber, + updates, + ); + + return res.status(200).json({ + message: 'Activities reordered successfully', + activities: updatedActivities, + }); + } catch (error) { + logger.error(error, 'Error reordering activities'); + + return res.status(500).json({ + message: 'Error reordering activities', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +}; diff --git a/src/routes/routes.ts b/src/routes/routes.ts index b623fc6..76269ef 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -24,6 +24,7 @@ import { handleSearchDestinations } from '../controllers/destinations/search-des import { handleUpdateTrip } from '../controllers/trips/update-trip'; import { handleCreateShareLink } from '../controllers/trips/share'; import { handleGetSharedTrip } from '../controllers/trips/get-shared-trip'; +import { handleReorderActivities } from '@/controllers/activities/reorder-activities'; const router = express.Router(); @@ -64,6 +65,11 @@ router.get('/trips', requireAuth, handleGetTrips); router.get('/trips/:id', requireAuth, handleGetTrip); router.post('/trips', llmLimiter, requireAuth, handleAddTrip); router.put('/trips/:tripId', requireAuth, handleUpdateTrip); +router.put( + '/trips/:tripId/days/:dayNumber/reorder', + requireAuth, + handleReorderActivities, +); router.delete('/trips/:tripId', requireAuth, handleDeleteTrip); // Share routes diff --git a/src/services/activity-service/db-operations.ts b/src/services/activity-service/db-operations.ts new file mode 100644 index 0000000..78e2b6d --- /dev/null +++ b/src/services/activity-service/db-operations.ts @@ -0,0 +1,123 @@ +import { and, eq, sql } from 'drizzle-orm'; +import { activities, db, itineraryDays } from '../../db'; +import { executeDbOperation } from '../../utils/db-utils'; +import { createDBQueryError } from '../../utils/error-handlers'; + +export const reorderActivities = async ( + tripId: number, + dayNumber: number, + updates: Array<{ activityId: number; slotNumber: number }>, +) => { + return executeDbOperation( + async () => { + // Validate slot numbers + const validSlots = updates.every( + (u) => u.slotNumber >= 1 && u.slotNumber <= 3, + ); + if (!validSlots) { + throw createDBQueryError('Slot numbers must be between 1 and 3', { + updates, + }); + } + + // Check for duplicate slots + const slots = new Set(updates.map((u) => u.slotNumber)); + if (slots.size !== updates.length) { + throw createDBQueryError('Duplicate slot numbers are not allowed', { + updates, + }); + } + + // Get current activities for this day + const currentActivities = await db + .select({ + activityId: activities.activityId, + dayId: itineraryDays.dayId, + slotNumber: itineraryDays.slotNumber, + }) + .from(itineraryDays) + .where( + and( + sql`${itineraryDays.tripId} = ${tripId}`, + sql`${itineraryDays.dayNumber} = ${dayNumber}`, + ), + ); + + // Validate that all activities exist in this day + const currentActivityIds = new Set( + currentActivities + .map((a) => a.activityId ?? -1) + .filter((id) => id !== -1), + ); + + // Validate that all activities exist in this day + const allActivitiesExist = updates.every((u) => + currentActivityIds.has(u.activityId), + ); + + if (!allActivitiesExist) { + throw createDBQueryError('Some activities do not exist in this day', { + updates, + existingActivities: currentActivityIds, + }); + } + + // Update slots in a transaction + await db.transaction(async (tx) => { + const updatePromises = updates.map(({ activityId, slotNumber }) => { + const activity = currentActivities.find( + (a) => a.activityId === activityId, + ); + if (!activity) return Promise.resolve(); // Should never happen due to earlier validation + + return tx + .update(itineraryDays) + .set({ slotNumber }) + .where(eq(itineraryDays.dayId, activity.dayId)); + }); + + await Promise.all(updatePromises); + }); + + // Return updated activities + return db + .select({ + activityId: activities.activityId, + activityName: activities.activityName, + latitude: activities.latitude, + longitude: activities.longitude, + price: activities.price, + location: activities.location, + description: activities.description, + duration: activities.duration, + difficulty: activities.difficulty, + category: activities.category, + bestTime: activities.bestTime, + slotNumber: itineraryDays.slotNumber, + }) + .from(itineraryDays) + .where( + and( + sql`${itineraryDays.tripId} = ${tripId}`, + sql`${itineraryDays.dayNumber} = ${dayNumber}`, + ), + ) + .leftJoin( + activities, + eq(activities.activityId, itineraryDays.activityId), + ) + .orderBy(itineraryDays.slotNumber); + }, + 'Error reordering activities', + { context: { tripId, dayNumber, updates } }, + ); +}; + +// We'll also need to properly move these functions from trip-service +export const addActivities = async (/* params */) => { + // Move from trip service +}; + +export const getActivitiesForDay = async (/* params */) => { + // Move from trip service +}; diff --git a/src/services/activity-service/index.ts b/src/services/activity-service/index.ts new file mode 100644 index 0000000..6f4c5d5 --- /dev/null +++ b/src/services/activity-service/index.ts @@ -0,0 +1 @@ +export * from './db-operations'; diff --git a/src/types/trip-types.ts b/src/types/trip-types.ts index e026558..55bd528 100644 --- a/src/types/trip-types.ts +++ b/src/types/trip-types.ts @@ -34,6 +34,13 @@ export interface Activity { bestTime: BestTime; } +export interface ActivityUpdate { + tripId: number; + dayNumber: number; + activityId: number; + slotNumber: number; +} + export interface DayItinerary { day: number; activities: Activity[]; From 26978db369cfc65ff23654c9f49580184a712670 Mon Sep 17 00:00:00 2001 From: Joshua Tuddenham Date: Sat, 30 Nov 2024 17:31:04 +0000 Subject: [PATCH 3/7] feat: added reorder trips with working fetch and add trip and integration test --- .../20241130170843_easy_human_cannonball.sql | 1 + drizzle/meta/20241130170843_snapshot.json | 527 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + drizzle/pg/0000_solid_darwin.sql | 94 ++++ drizzle/pg/meta/0000_snapshot.json | 527 ++++++++++++++++++ drizzle/pg/meta/_journal.json | 13 + .../activity-service/db-operations.ts | 33 +- src/services/itinerary-service.ts | 14 +- src/services/trip-service/db-operations.ts | 11 +- src/tests/integration/trips.test.ts | 70 +++ src/types/db-types.ts | 2 + src/utils/reshape-trip-data-drizzle.ts | 66 ++- 12 files changed, 1322 insertions(+), 43 deletions(-) create mode 100644 drizzle/20241130170843_easy_human_cannonball.sql create mode 100644 drizzle/meta/20241130170843_snapshot.json create mode 100644 drizzle/pg/0000_solid_darwin.sql create mode 100644 drizzle/pg/meta/0000_snapshot.json create mode 100644 drizzle/pg/meta/_journal.json diff --git a/drizzle/20241130170843_easy_human_cannonball.sql b/drizzle/20241130170843_easy_human_cannonball.sql new file mode 100644 index 0000000..9c45bc9 --- /dev/null +++ b/drizzle/20241130170843_easy_human_cannonball.sql @@ -0,0 +1 @@ +ALTER TABLE "itinerary_days" ADD COLUMN "slot_number" integer; \ No newline at end of file diff --git a/drizzle/meta/20241130170843_snapshot.json b/drizzle/meta/20241130170843_snapshot.json new file mode 100644 index 0000000..f1cd933 --- /dev/null +++ b/drizzle/meta/20241130170843_snapshot.json @@ -0,0 +1,527 @@ +{ + "id": "32edc205-aa75-400f-9e70-8391d0bfa37b", + "prevId": "84cba25e-3f3e-46ba-ad5f-cb4e5871c1b8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activities": { + "name": "activities", + "schema": "", + "columns": { + "activity_id": { + "name": "activity_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "activity_name": { + "name": "activity_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "price": { + "name": "price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "best_time": { + "name": "best_time", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.destinations": { + "name": "destinations", + "schema": "", + "columns": { + "destination_id": { + "name": "destination_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "destination_name": { + "name": "destination_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_name": { + "name": "normalized_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latitude": { + "name": "latitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "best_time_to_visit": { + "name": "best_time_to_visit", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "average_temperature_low": { + "name": "average_temperature_low", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "average_temperature_high": { + "name": "average_temperature_high", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "popular_activities": { + "name": "popular_activities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "travel_tips": { + "name": "travel_tips", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nearby_attractions": { + "name": "nearby_attractions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transportation_options": { + "name": "transportation_options", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessibility_info": { + "name": "accessibility_info", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "official_language": { + "name": "official_language", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "local_cuisine": { + "name": "local_cuisine", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_level": { + "name": "cost_level", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "safety_rating": { + "name": "safety_rating", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cultural_significance": { + "name": "cultural_significance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_ratings": { + "name": "user_ratings", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "destinations_normalized_name_unique": { + "name": "destinations_normalized_name_unique", + "nullsNotDistinct": false, + "columns": [ + "normalized_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.itinerary_days": { + "name": "itinerary_days", + "schema": "", + "columns": { + "day_id": { + "name": "day_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "trip_id": { + "name": "trip_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "day_number": { + "name": "day_number", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "activity_id": { + "name": "activity_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "slot_number": { + "name": "slot_number", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_destinations": { + "name": "saved_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "destination_id": { + "name": "destination_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_visited": { + "name": "is_visited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "saved_destinations_destination_id_destinations_destination_id_fk": { + "name": "saved_destinations_destination_id_destinations_destination_id_fk", + "tableFrom": "saved_destinations", + "tableTo": "destinations", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "destination_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_trips": { + "name": "shared_trips", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trip_id": { + "name": "trip_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "share_code": { + "name": "share_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "shared_trips_trip_id_trips_trip_id_fk": { + "name": "shared_trips_trip_id_trips_trip_id_fk", + "tableFrom": "shared_trips", + "tableTo": "trips", + "columnsFrom": [ + "trip_id" + ], + "columnsTo": [ + "trip_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shared_trips_share_code_unique": { + "name": "shared_trips_share_code_unique", + "nullsNotDistinct": false, + "columns": [ + "share_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trips": { + "name": "trips", + "schema": "", + "columns": { + "trip_id": { + "name": "trip_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "destination_id": { + "name": "destination_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "num_days": { + "name": "num_days", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PLANNING'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 391d49f..c91ef4a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1732625340355, "tag": "20241126124900_strong_tyger_tiger", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1732986523843, + "tag": "20241130170843_easy_human_cannonball", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/pg/0000_solid_darwin.sql b/drizzle/pg/0000_solid_darwin.sql new file mode 100644 index 0000000..19f313f --- /dev/null +++ b/drizzle/pg/0000_solid_darwin.sql @@ -0,0 +1,94 @@ +CREATE TABLE IF NOT EXISTS "activities" ( + "activity_id" serial PRIMARY KEY NOT NULL, + "activity_name" text, + "description" text, + "location" text, + "latitude" numeric, + "longitude" numeric, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "price" text, + "location_id" integer, + "duration" text, + "difficulty" text, + "category" text, + "best_time" text +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "destinations" ( + "destination_id" serial PRIMARY KEY NOT NULL, + "destination_name" text NOT NULL, + "normalized_name" text NOT NULL, + "latitude" numeric, + "longitude" numeric, + "description" text, + "country" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "best_time_to_visit" varchar, + "average_temperature_low" text, + "average_temperature_high" text, + "popular_activities" text, + "travel_tips" text, + "nearby_attractions" text, + "transportation_options" text, + "accessibility_info" text, + "official_language" varchar, + "currency" varchar, + "local_cuisine" text, + "cost_level" varchar, + "safety_rating" text, + "cultural_significance" text, + "user_ratings" text, + CONSTRAINT "destinations_normalized_name_unique" UNIQUE("normalized_name") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "itinerary_days" ( + "day_id" serial PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "trip_id" bigint, + "day_number" bigint, + "activity_id" bigint, + "slot_number" integer +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "saved_destinations" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" uuid NOT NULL, + "destination_id" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "notes" text, + "is_visited" boolean DEFAULT false +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "shared_trips" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" uuid NOT NULL, + "trip_id" serial NOT NULL, + "share_code" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone, + CONSTRAINT "shared_trips_share_code_unique" UNIQUE("share_code") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "trips" ( + "trip_id" serial PRIMARY KEY NOT NULL, + "user_id" uuid NOT NULL, + "destination_id" bigint, + "start_date" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "num_days" bigint, + "title" text, + "description" text, + "status" text DEFAULT 'PLANNING' NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "saved_destinations" ADD CONSTRAINT "saved_destinations_destination_id_destinations_destination_id_fk" FOREIGN KEY ("destination_id") REFERENCES "public"."destinations"("destination_id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "shared_trips" ADD CONSTRAINT "shared_trips_trip_id_trips_trip_id_fk" FOREIGN KEY ("trip_id") REFERENCES "public"."trips"("trip_id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/drizzle/pg/meta/0000_snapshot.json b/drizzle/pg/meta/0000_snapshot.json new file mode 100644 index 0000000..34b114b --- /dev/null +++ b/drizzle/pg/meta/0000_snapshot.json @@ -0,0 +1,527 @@ +{ + "id": "314e454c-0ac3-4d3d-8e9e-8fa0266a7756", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activities": { + "name": "activities", + "schema": "", + "columns": { + "activity_id": { + "name": "activity_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "activity_name": { + "name": "activity_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "price": { + "name": "price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "best_time": { + "name": "best_time", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.destinations": { + "name": "destinations", + "schema": "", + "columns": { + "destination_id": { + "name": "destination_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "destination_name": { + "name": "destination_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_name": { + "name": "normalized_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latitude": { + "name": "latitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "best_time_to_visit": { + "name": "best_time_to_visit", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "average_temperature_low": { + "name": "average_temperature_low", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "average_temperature_high": { + "name": "average_temperature_high", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "popular_activities": { + "name": "popular_activities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "travel_tips": { + "name": "travel_tips", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nearby_attractions": { + "name": "nearby_attractions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transportation_options": { + "name": "transportation_options", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessibility_info": { + "name": "accessibility_info", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "official_language": { + "name": "official_language", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "local_cuisine": { + "name": "local_cuisine", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_level": { + "name": "cost_level", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "safety_rating": { + "name": "safety_rating", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cultural_significance": { + "name": "cultural_significance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_ratings": { + "name": "user_ratings", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "destinations_normalized_name_unique": { + "name": "destinations_normalized_name_unique", + "nullsNotDistinct": false, + "columns": [ + "normalized_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.itinerary_days": { + "name": "itinerary_days", + "schema": "", + "columns": { + "day_id": { + "name": "day_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "trip_id": { + "name": "trip_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "day_number": { + "name": "day_number", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "activity_id": { + "name": "activity_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "slot_number": { + "name": "slot_number", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_destinations": { + "name": "saved_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "destination_id": { + "name": "destination_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_visited": { + "name": "is_visited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "saved_destinations_destination_id_destinations_destination_id_fk": { + "name": "saved_destinations_destination_id_destinations_destination_id_fk", + "tableFrom": "saved_destinations", + "tableTo": "destinations", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "destination_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_trips": { + "name": "shared_trips", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trip_id": { + "name": "trip_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "share_code": { + "name": "share_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "shared_trips_trip_id_trips_trip_id_fk": { + "name": "shared_trips_trip_id_trips_trip_id_fk", + "tableFrom": "shared_trips", + "tableTo": "trips", + "columnsFrom": [ + "trip_id" + ], + "columnsTo": [ + "trip_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shared_trips_share_code_unique": { + "name": "shared_trips_share_code_unique", + "nullsNotDistinct": false, + "columns": [ + "share_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trips": { + "name": "trips", + "schema": "", + "columns": { + "trip_id": { + "name": "trip_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "destination_id": { + "name": "destination_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "num_days": { + "name": "num_days", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PLANNING'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/pg/meta/_journal.json b/drizzle/pg/meta/_journal.json new file mode 100644 index 0000000..df7a3ba --- /dev/null +++ b/drizzle/pg/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1732986511176, + "tag": "0000_solid_darwin", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/services/activity-service/db-operations.ts b/src/services/activity-service/db-operations.ts index 78e2b6d..70de344 100644 --- a/src/services/activity-service/db-operations.ts +++ b/src/services/activity-service/db-operations.ts @@ -41,16 +41,18 @@ export const reorderActivities = async ( sql`${itineraryDays.tripId} = ${tripId}`, sql`${itineraryDays.dayNumber} = ${dayNumber}`, ), + ) + .leftJoin( + activities, + eq(activities.activityId, itineraryDays.activityId), ); - // Validate that all activities exist in this day const currentActivityIds = new Set( currentActivities .map((a) => a.activityId ?? -1) .filter((id) => id !== -1), ); - // Validate that all activities exist in this day const allActivitiesExist = updates.every((u) => currentActivityIds.has(u.activityId), ); @@ -58,7 +60,7 @@ export const reorderActivities = async ( if (!allActivitiesExist) { throw createDBQueryError('Some activities do not exist in this day', { updates, - existingActivities: currentActivityIds, + existingActivities: Array.from(currentActivityIds), }); } @@ -68,7 +70,7 @@ export const reorderActivities = async ( const activity = currentActivities.find( (a) => a.activityId === activityId, ); - if (!activity) return Promise.resolve(); // Should never happen due to earlier validation + if (!activity) return Promise.resolve(); return tx .update(itineraryDays) @@ -79,17 +81,17 @@ export const reorderActivities = async ( await Promise.all(updatePromises); }); - // Return updated activities - return db + // Return only the updated activities for this specific day + const updatedActivities = await db .select({ activityId: activities.activityId, activityName: activities.activityName, - latitude: activities.latitude, - longitude: activities.longitude, - price: activities.price, - location: activities.location, description: activities.description, + location: activities.location, + price: activities.price, duration: activities.duration, + latitude: activities.latitude, + longitude: activities.longitude, difficulty: activities.difficulty, category: activities.category, bestTime: activities.bestTime, @@ -107,17 +109,10 @@ export const reorderActivities = async ( eq(activities.activityId, itineraryDays.activityId), ) .orderBy(itineraryDays.slotNumber); + + return updatedActivities; }, 'Error reordering activities', { context: { tripId, dayNumber, updates } }, ); }; - -// We'll also need to properly move these functions from trip-service -export const addActivities = async (/* params */) => { - // Move from trip service -}; - -export const getActivitiesForDay = async (/* params */) => { - // Move from trip service -}; diff --git a/src/services/itinerary-service.ts b/src/services/itinerary-service.ts index a8a33a6..5ece7a4 100644 --- a/src/services/itinerary-service.ts +++ b/src/services/itinerary-service.ts @@ -8,6 +8,13 @@ import { createValidationError, } from '../utils/error-handlers'; +interface ItineraryDayData { + tripId: number; + dayNumber: number; + activityId: number; + slotNumber: number; +} + export const addItineraryDays = async ( tripId: number, itinerary: DayItinerary[], @@ -29,7 +36,7 @@ export const addItineraryDays = async ( }); } - const itineraryDaysData = []; + const itineraryDaysData: ItineraryDayData[] = []; for (let i = 0; i < itinerary.length; i++) { const dayNumber = i + 1; @@ -40,13 +47,14 @@ export const addItineraryDays = async ( throw createValidationError(errorMessage, { tripId, dayNumber }); } - for (const activityId of activityIds[i]) { + activityIds[i].forEach((activityId, slotIndex) => { itineraryDaysData.push({ tripId, dayNumber, activityId, + slotNumber: slotIndex + 1, }); - } + }); } try { diff --git a/src/services/trip-service/db-operations.ts b/src/services/trip-service/db-operations.ts index b340fb8..c904a5d 100644 --- a/src/services/trip-service/db-operations.ts +++ b/src/services/trip-service/db-operations.ts @@ -25,6 +25,7 @@ export const fetchTripsFromDB = (userId: string) => description: trips.description, status: trips.status, itineraryDays: itineraryDays.dayNumber, + slotNumber: itineraryDays.slotNumber, activities: { activityId: activities.activityId, activityName: activities.activityName, @@ -160,6 +161,7 @@ export const fetchTripFromDB = (tripId: string, userId?: string) => description: trips.description, status: trips.status, itineraryDays: itineraryDays.dayNumber, + slotNumber: itineraryDays.slotNumber, activities: { activityId: activities.activityId, activityName: activities.activityName, @@ -298,16 +300,7 @@ export async function createTripInDB( days: number, itinerary: DayItinerary[], ): Promise { - console.log('createTripInDB itinerary:', JSON.stringify(itinerary, null, 2)); const tripId = await addTrip(userId, destinationId, startDate, days); - console.log('Created trip with ID:', tripId); - - console.log('About to call addActivities with:', { - itineraryType: typeof itinerary, - isArray: Array.isArray(itinerary), - length: itinerary?.length, - }); - const activityIds = await addActivities(itinerary, destinationId); await addItineraryDays(tripId, itinerary, activityIds); return tripId; diff --git a/src/tests/integration/trips.test.ts b/src/tests/integration/trips.test.ts index 00ffa52..51fbabf 100644 --- a/src/tests/integration/trips.test.ts +++ b/src/tests/integration/trips.test.ts @@ -301,4 +301,74 @@ describe('Trips API', () => { }, }); }); + + it('can reorder activities within a day', async () => { + // First create a trip with activities using your existing flow + setLLMResponse([ + { type: 'success', dataType: 'destination', location: 'tokyo' }, + { type: 'success', dataType: 'trip', location: 'tokyo' }, + ]); + + const createResponse = await api + .post('/api/trips') + .set('Authorization', authHeader) + .send({ + days: 2, + location: 'Tokyo', + startDate: '2024-12-25', + }) + .expect(201); + + const tripId = createResponse.body.trip.tripId; + const dayNumber = 1; + + // Get the current activities to know their IDs + const tripResponse = await api + .get(`/api/trips/${tripId}`) + .set('Authorization', authHeader) + .expect(200); + + const currentActivities = tripResponse.body.trip.itinerary[0].activities; + + // Initially: Museum = slot 1, Senso-ji = slot 2 + // Let's swap their positions + const reorderResponse = await api + .put(`/api/trips/${tripId}/days/${dayNumber}/reorder`) + .set('Authorization', authHeader) + .send({ + updates: [ + { activityId: currentActivities[0].activityId, slotNumber: 2 }, // Move Museum to slot 2 + { activityId: currentActivities[1].activityId, slotNumber: 1 }, // Move Senso-ji to slot 1 + ], + }) + .expect(200); + + expect(reorderResponse.body).toMatchObject({ + message: 'Activities reordered successfully', + activities: expect.arrayContaining([ + expect.objectContaining({ + activityId: expect.any(Number), + activityName: expect.any(String), + slotNumber: expect.any(Number), + }), + ]), + }); + + // Verify the new order persisted + const verifyResponse = await api + .get(`/api/trips/${tripId}`) + .set('Authorization', authHeader) + .expect(200); + + const updatedActivities = verifyResponse.body.trip.itinerary[0].activities; + + // First activity should now be Senso-ji (originally second) + expect(updatedActivities[0].activityName).toContain('Senso-ji'); + // Second activity should now be Museum (originally first) + expect(updatedActivities[1].activityName).toContain('Museum'); + + // Verify slot numbers + expect(updatedActivities[0].slotNumber).toBe(1); + expect(updatedActivities[1].slotNumber).toBe(2); + }); }); diff --git a/src/types/db-types.ts b/src/types/db-types.ts index 2102c8e..24891db 100644 --- a/src/types/db-types.ts +++ b/src/types/db-types.ts @@ -13,6 +13,7 @@ export interface DBActivity { difficulty: Difficulty; category: Category; bestTime: BestTime; + slotNumber: number | null; } export interface DBDestination { @@ -31,6 +32,7 @@ export interface TripDBRow { startDate: Date | null; numDays: number | null; itineraryDays: number | null; + slotNumber: number | null; status: string; title: string | null; description: string | null; diff --git a/src/utils/reshape-trip-data-drizzle.ts b/src/utils/reshape-trip-data-drizzle.ts index 1881cc2..28ee19e 100644 --- a/src/utils/reshape-trip-data-drizzle.ts +++ b/src/utils/reshape-trip-data-drizzle.ts @@ -1,9 +1,42 @@ -import { DBActivity, TripDBRow, DBItineraryDay } from '../types/db-types'; +import { TripDBRow, DBItineraryDay, DBActivity } from '../types/db-types'; + +interface Trip { + tripId: string; + startDate: Date | null; + status: string; + numDays: number | null; + description: string | null; + title: string | null; + destination: { + destinationId?: number | undefined; + destinationName: string; + latitude: string; + longitude: string; + description: string; + country: string; + bestTimeToVisit: string; + averageTemperatureLow: string; + averageTemperatureHigh: string; + popularActivities: string; + travelTips: string; + nearbyAttractions: string; + transportationOptions: string; + accessibilityInfo: string; + officialLanguage: string; + currency: string; + localCuisine: string; + costLevel: string; + safetyRating: string; + culturalSignificance: string; + userRatings: string; + }; + itinerary: DBItineraryDay[]; +} // Reshape the data returned by Drizzle ORM function reshapeTripData(dbData: TripDBRow[]) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tripsMap: any = {}; + const tripsMap: Record = {}; dbData.forEach((row: TripDBRow) => { // If the trip is not yet in tripsMap, add it @@ -45,12 +78,10 @@ function reshapeTripData(dbData: TripDBRow[]) { // Handle itinerary day grouping const itineraryDay = tripsMap[row.tripId].itinerary.find( - (day: DBItineraryDay) => { - return day.day === row.itineraryDays; - }, + (day: DBItineraryDay) => day.day === row.itineraryDays, ); - const activity: DBActivity | null = + const activity = row.activities && row.activities.activityId ? { activityId: row.activities.activityId, @@ -68,27 +99,38 @@ function reshapeTripData(dbData: TripDBRow[]) { difficulty: row.activities.difficulty, category: row.activities.category, bestTime: row.activities.bestTime, + slotNumber: row.slotNumber, } : null; - // If the day already exists, just push the non-null activity + // If the day exists, add activity and sort by slot number if (itineraryDay) { if (activity) { itineraryDay.activities.push(activity); } } else { + // Create new day with activity tripsMap[row.tripId].itinerary.push({ - day: row.itineraryDays, + day: row.itineraryDays ?? 1, activities: activity ? [activity] : [], }); } }); - const result = Object.values(tripsMap); + for (const trip of Object.values(tripsMap)) { + trip.itinerary.sort( + (a: DBItineraryDay, b: DBItineraryDay) => a.day - b.day, + ); + + trip.itinerary.forEach((day: DBItineraryDay) => { + day.activities.sort( + (a: DBActivity, b: DBActivity) => + (a.slotNumber ?? 1) - (b.slotNumber ?? 1), + ); + }); + } - // Return the object values as an array of trips - console.log(result); - return result; + return Object.values(tripsMap); } export default reshapeTripData; From 5829bd50627c477113b9a82aa2e2257d2f5f762b Mon Sep 17 00:00:00 2001 From: Joshua Tuddenham Date: Sun, 1 Dec 2024 07:12:22 +0000 Subject: [PATCH 4/7] test: added test for failure state for activity reorder --- src/tests/integration/activities.test.ts | 162 +++++++++++++++++++++++ src/tests/integration/trips.test.ts | 70 ---------- 2 files changed, 162 insertions(+), 70 deletions(-) create mode 100644 src/tests/integration/activities.test.ts diff --git a/src/tests/integration/activities.test.ts b/src/tests/integration/activities.test.ts new file mode 100644 index 0000000..ca5373e --- /dev/null +++ b/src/tests/integration/activities.test.ts @@ -0,0 +1,162 @@ +import { testDb } from '../setup/test-db'; + +jest.mock('../../db', () => { + const originalModule = jest.requireActual('../../db'); + return { + ...originalModule, + db: testDb, + }; +}); + +import { mockGeminiClient, setLLMResponse } from '@/__mocks__/llm'; +import { requireAuth } from '@/__mocks__/auth-middleware'; + +jest.mock('@/middleware/auth-middleware', () => ({ + requireAuth: requireAuth, +})); + +jest.mock('@google/generative-ai', () => { + return { + GoogleGenerativeAI: jest.fn().mockImplementation(() => { + return mockGeminiClient; + }), + }; +}); + +import request from 'supertest'; +import app from '@/index'; + +import { activities, destinations, itineraryDays, trips } from '@/db'; +import { resetSequences } from '../utils/reset-sequence'; + +const api = request(app); + +describe('Activities API', () => { + const authHeader = 'Bearer test-token'; + + beforeAll(async () => { + process.env.GEMINI_API_KEY = 'test-key'; + }); + + beforeEach(async () => { + await testDb.delete(itineraryDays).execute(); + await testDb.delete(activities).execute(); + await testDb.delete(trips).execute(); + await testDb.delete(destinations).execute(); + await resetSequences(); + }); + + it('can reorder activities within a day', async () => { + // First create a trip with activities + setLLMResponse([ + { type: 'success', dataType: 'destination', location: 'tokyo' }, + { type: 'success', dataType: 'trip', location: 'tokyo' }, + ]); + + const createResponse = await api + .post('/api/trips') + .set('Authorization', authHeader) + .send({ + days: 2, + location: 'Tokyo', + startDate: '2024-12-25', + }) + .expect(201); + + const tripId = createResponse.body.trip.tripId; + const dayNumber = 1; + + // Get the current activities to know their IDs + const tripResponse = await api + .get(`/api/trips/${tripId}`) + .set('Authorization', authHeader) + .expect(200); + + const currentActivities = tripResponse.body.trip.itinerary[0].activities; + + // Initially: Museum = slot 1, Senso-ji = slot 2 + // Swap their positions + const reorderResponse = await api + .put(`/api/trips/${tripId}/days/${dayNumber}/reorder`) + .set('Authorization', authHeader) + .send({ + updates: [ + { activityId: currentActivities[0].activityId, slotNumber: 2 }, // Move Museum to slot 2 + { activityId: currentActivities[1].activityId, slotNumber: 1 }, // Move Senso-ji to slot 1 + ], + }) + .expect(200); + + expect(reorderResponse.body).toMatchObject({ + message: 'Activities reordered successfully', + activities: expect.arrayContaining([ + expect.objectContaining({ + activityId: expect.any(Number), + activityName: expect.any(String), + slotNumber: expect.any(Number), + }), + ]), + }); + + // Verify the new order persisted + const verifyResponse = await api + .get(`/api/trips/${tripId}`) + .set('Authorization', authHeader) + .expect(200); + + const updatedActivities = verifyResponse.body.trip.itinerary[0].activities; + + // First activity should now be Senso-ji (originally second) + expect(updatedActivities[0].activityName).toContain('Senso-ji'); + // Second activity should now be Museum (originally first) + expect(updatedActivities[1].activityName).toContain('Museum'); + + // Verify slot numbers + expect(updatedActivities[0].slotNumber).toBe(1); + expect(updatedActivities[1].slotNumber).toBe(2); + }); + + it('returns 400 for invalid reordering request', async () => { + setLLMResponse([ + { type: 'success', dataType: 'destination', location: 'tokyo' }, + { type: 'success', dataType: 'trip', location: 'tokyo' }, + ]); + + const createResponse = await api + .post('/api/trips') + .set('Authorization', authHeader) + .send({ + days: 2, + location: 'Tokyo', + startDate: '2024-12-25', + }) + .expect(201); + + const tripId = createResponse.body.trip.tripId; + const dayNumber = 1; + + // Test duplicate slot numbers + await api + .put(`/api/trips/${tripId}/days/${dayNumber}/reorder`) + .set('Authorization', authHeader) + .send({ + updates: [ + { activityId: 1, slotNumber: 1 }, + { activityId: 2, slotNumber: 1 }, // Duplicate slot + ], + }) + .expect(400); + + // Test invalid slot numbers + await api + .put(`/api/trips/${tripId}/days/${dayNumber}/reorder`) + .set('Authorization', authHeader) + .send({ + updates: [ + { activityId: 1, slotNumber: 0 }, // Invalid slot + { activityId: 2, slotNumber: 4 }, // Invalid slot + ], + }) + .expect(400); + }); +}); diff --git a/src/tests/integration/trips.test.ts b/src/tests/integration/trips.test.ts index 51fbabf..00ffa52 100644 --- a/src/tests/integration/trips.test.ts +++ b/src/tests/integration/trips.test.ts @@ -301,74 +301,4 @@ describe('Trips API', () => { }, }); }); - - it('can reorder activities within a day', async () => { - // First create a trip with activities using your existing flow - setLLMResponse([ - { type: 'success', dataType: 'destination', location: 'tokyo' }, - { type: 'success', dataType: 'trip', location: 'tokyo' }, - ]); - - const createResponse = await api - .post('/api/trips') - .set('Authorization', authHeader) - .send({ - days: 2, - location: 'Tokyo', - startDate: '2024-12-25', - }) - .expect(201); - - const tripId = createResponse.body.trip.tripId; - const dayNumber = 1; - - // Get the current activities to know their IDs - const tripResponse = await api - .get(`/api/trips/${tripId}`) - .set('Authorization', authHeader) - .expect(200); - - const currentActivities = tripResponse.body.trip.itinerary[0].activities; - - // Initially: Museum = slot 1, Senso-ji = slot 2 - // Let's swap their positions - const reorderResponse = await api - .put(`/api/trips/${tripId}/days/${dayNumber}/reorder`) - .set('Authorization', authHeader) - .send({ - updates: [ - { activityId: currentActivities[0].activityId, slotNumber: 2 }, // Move Museum to slot 2 - { activityId: currentActivities[1].activityId, slotNumber: 1 }, // Move Senso-ji to slot 1 - ], - }) - .expect(200); - - expect(reorderResponse.body).toMatchObject({ - message: 'Activities reordered successfully', - activities: expect.arrayContaining([ - expect.objectContaining({ - activityId: expect.any(Number), - activityName: expect.any(String), - slotNumber: expect.any(Number), - }), - ]), - }); - - // Verify the new order persisted - const verifyResponse = await api - .get(`/api/trips/${tripId}`) - .set('Authorization', authHeader) - .expect(200); - - const updatedActivities = verifyResponse.body.trip.itinerary[0].activities; - - // First activity should now be Senso-ji (originally second) - expect(updatedActivities[0].activityName).toContain('Senso-ji'); - // Second activity should now be Museum (originally first) - expect(updatedActivities[1].activityName).toContain('Museum'); - - // Verify slot numbers - expect(updatedActivities[0].slotNumber).toBe(1); - expect(updatedActivities[1].slotNumber).toBe(2); - }); }); From 235256ee1fb1525db123a3637efc0d56f0533b5a Mon Sep 17 00:00:00 2001 From: Joshua Tuddenham Date: Sun, 1 Dec 2024 07:12:54 +0000 Subject: [PATCH 5/7] chore: removed unused test db schema --- src/db/test/activities.ts | 20 -------------------- src/db/test/destinations.ts | 30 ------------------------------ src/db/test/index.ts | 5 ----- src/db/test/itinerary_days.ts | 12 ------------ src/db/test/saved_destinations.ts | 16 ---------------- src/db/test/trips.ts | 13 ------------- 6 files changed, 96 deletions(-) delete mode 100644 src/db/test/activities.ts delete mode 100644 src/db/test/destinations.ts delete mode 100644 src/db/test/index.ts delete mode 100644 src/db/test/itinerary_days.ts delete mode 100644 src/db/test/saved_destinations.ts delete mode 100644 src/db/test/trips.ts diff --git a/src/db/test/activities.ts b/src/db/test/activities.ts deleted file mode 100644 index b148b0b..0000000 --- a/src/db/test/activities.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { sql } from 'drizzle-orm'; -import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'; - -export const activities = sqliteTable('activities', { - activityId: integer('activity_id').primaryKey({ autoIncrement: true }), - activityName: text('activity_name'), - description: text('description'), - location: text('location'), - latitude: real('latitude'), - longitude: real('longitude'), - createdAt: text('created_at') - .default(sql`CURRENT_TIMESTAMP`) - .notNull(), - price: text('price'), - locationId: integer('location_id'), - duration: text('duration'), - difficulty: text('difficulty'), - category: text('category'), - bestTime: text('best_time'), -}); diff --git a/src/db/test/destinations.ts b/src/db/test/destinations.ts deleted file mode 100644 index 57a38e4..0000000 --- a/src/db/test/destinations.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { sql } from 'drizzle-orm'; -import { sqliteTable, text, real, integer } from 'drizzle-orm/sqlite-core'; - -export const destinations = sqliteTable('destinations', { - destinationId: integer('destination_id').primaryKey({ autoIncrement: true }), - destinationName: text('destination_name').notNull(), - normalizedName: text('normalized_name').notNull().unique(), - latitude: real('latitude'), - longitude: real('longitude'), - description: text('description'), - country: text('country'), - createdAt: text('created_at') - .default(sql`CURRENT_TIMESTAMP`) - .notNull(), - bestTimeToVisit: text('best_time_to_visit'), - averageTemperatureLow: text('average_temperature_low'), - averageTemperatureHigh: text('average_temperature_high'), - popularActivities: text('popular_activities'), - travelTips: text('travel_tips'), - nearbyAttractions: text('nearby_attractions'), - transportationOptions: text('transportation_options'), - accessibilityInfo: text('accessibility_info'), - officialLanguage: text('official_language'), - currency: text('currency'), - localCuisine: text('local_cuisine'), - costLevel: text('cost_level'), - safetyRating: text('safety_rating'), - culturalSignificance: text('cultural_significance'), - userRatings: text('user_ratings'), -}); diff --git a/src/db/test/index.ts b/src/db/test/index.ts deleted file mode 100644 index fae5e1d..0000000 --- a/src/db/test/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { activities } from './activities'; -export { destinations } from './destinations'; -export { itineraryDays } from './itinerary_days'; -export { savedDestinations } from './saved_destinations'; -export { trips } from './trips'; diff --git a/src/db/test/itinerary_days.ts b/src/db/test/itinerary_days.ts deleted file mode 100644 index 7bf9e14..0000000 --- a/src/db/test/itinerary_days.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; -import { sql } from 'drizzle-orm'; - -export const itineraryDays = sqliteTable('itinerary_days', { - dayId: integer('day_id').primaryKey({ autoIncrement: true }), - createdAt: text('created_at') - .default(sql`CURRENT_TIMESTAMP`) - .notNull(), - tripId: integer('trip_id'), - dayNumber: integer('day_number'), - activityId: integer('activity_id'), -}); diff --git a/src/db/test/saved_destinations.ts b/src/db/test/saved_destinations.ts deleted file mode 100644 index 71d2d4a..0000000 --- a/src/db/test/saved_destinations.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; -import { sql } from 'drizzle-orm'; -import { destinations } from './destinations'; - -export const savedDestinations = sqliteTable('saved_destinations', { - id: integer('id').primaryKey({ autoIncrement: true }), - userId: text('user_id').notNull(), - destinationId: integer('destination_id') - .notNull() - .references(() => destinations.destinationId, { onDelete: 'cascade' }), - createdAt: text('created_at') - .default(sql`CURRENT_TIMESTAMP`) - .notNull(), - notes: text('notes'), - isVisited: integer('is_visited', { mode: 'boolean' }).default(false), -}); diff --git a/src/db/test/trips.ts b/src/db/test/trips.ts deleted file mode 100644 index 5abf493..0000000 --- a/src/db/test/trips.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; -import { sql } from 'drizzle-orm'; - -export const trips = sqliteTable('trips', { - tripId: integer('trip_id').primaryKey({ autoIncrement: true }), - userId: text('user_id').notNull(), - destinationId: integer('destination_id'), - startDate: text('start_date'), - createdAt: text('created_at') - .default(sql`CURRENT_TIMESTAMP`) - .notNull(), - numDays: integer('num_days'), -}); From ef14cfa234cc0f2610a7322e128304f1eb2a5aa6 Mon Sep 17 00:00:00 2001 From: Joshua Tuddenham Date: Sun, 1 Dec 2024 07:20:30 +0000 Subject: [PATCH 6/7] test: all activity reorder error states --- src/tests/integration/activities.test.ts | 29 ++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/tests/integration/activities.test.ts b/src/tests/integration/activities.test.ts index ca5373e..31fe639 100644 --- a/src/tests/integration/activities.test.ts +++ b/src/tests/integration/activities.test.ts @@ -135,14 +135,23 @@ describe('Activities API', () => { const tripId = createResponse.body.trip.tripId; const dayNumber = 1; + // Get the actual activity IDs from our created trip + const tripResponse = await api + .get(`/api/trips/${tripId}`) + .set('Authorization', authHeader) + .expect(200); + + const day1Activities = tripResponse.body.trip.itinerary[0].activities; + const day2Activities = tripResponse.body.trip.itinerary[1].activities; + // Test duplicate slot numbers await api .put(`/api/trips/${tripId}/days/${dayNumber}/reorder`) .set('Authorization', authHeader) .send({ updates: [ - { activityId: 1, slotNumber: 1 }, - { activityId: 2, slotNumber: 1 }, // Duplicate slot + { activityId: day1Activities[0].activityId, slotNumber: 1 }, + { activityId: day1Activities[1].activityId, slotNumber: 1 }, // Duplicate slot ], }) .expect(400); @@ -153,10 +162,22 @@ describe('Activities API', () => { .set('Authorization', authHeader) .send({ updates: [ - { activityId: 1, slotNumber: 0 }, // Invalid slot - { activityId: 2, slotNumber: 4 }, // Invalid slot + { activityId: day1Activities[0].activityId, slotNumber: 0 }, + { activityId: day1Activities[1].activityId, slotNumber: 4 }, ], }) .expect(400); + + // Test activity from different day + await api + .put(`/api/trips/${tripId}/days/${dayNumber}/reorder`) + .set('Authorization', authHeader) + .send({ + updates: [ + { activityId: day2Activities[0].activityId, slotNumber: 1 }, // Activity from day 2 + { activityId: day1Activities[0].activityId, slotNumber: 2 }, + ], + }) + .expect(500); }); }); From 11b4a4fa67a24be056c0c6751bee0acd3929e205 Mon Sep 17 00:00:00 2001 From: Joshua Tuddenham Date: Sun, 1 Dec 2024 07:39:59 +0000 Subject: [PATCH 7/7] fix: relative imports of modules --- src/controllers/activities/reorder-activities.ts | 2 +- src/routes/routes.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/activities/reorder-activities.ts b/src/controllers/activities/reorder-activities.ts index c56e741..dbefb89 100644 --- a/src/controllers/activities/reorder-activities.ts +++ b/src/controllers/activities/reorder-activities.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { logger } from '../../utils/logger'; -import { reorderActivities } from '@/services/activity-service/'; +import { reorderActivities } from '../../services/activity-service/'; export const handleReorderActivities = async (req: Request, res: Response) => { try { diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 76269ef..81f7bac 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -24,7 +24,7 @@ import { handleSearchDestinations } from '../controllers/destinations/search-des import { handleUpdateTrip } from '../controllers/trips/update-trip'; import { handleCreateShareLink } from '../controllers/trips/share'; import { handleGetSharedTrip } from '../controllers/trips/get-shared-trip'; -import { handleReorderActivities } from '@/controllers/activities/reorder-activities'; +import { handleReorderActivities } from '../controllers/activities/reorder-activities'; const router = express.Router();