From dacead818018d4191c9c372da9f5cf5b23e6369d Mon Sep 17 00:00:00 2001 From: Joshua Tuddenham Date: Mon, 25 Nov 2024 11:02:08 +0000 Subject: [PATCH 1/4] feat: PUT trips --- .github/workflows/deploy.yml | 47 +-- drizzle/20241124234923_majestic_polaris.sql | 2 + drizzle/meta/20241124234923_snapshot.json | 442 ++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/controllers/trips/update-trip.ts | 41 ++ src/db/tables/trips.ts | 11 +- src/routes/routes.ts | 2 + src/services/trip-service/db-operations.ts | 57 +++ src/types/db-types.ts | 2 + src/utils/reshape-trip-data-drizzle.ts | 2 + 10 files changed, 588 insertions(+), 25 deletions(-) create mode 100644 drizzle/20241124234923_majestic_polaris.sql create mode 100644 drizzle/meta/20241124234923_snapshot.json create mode 100644 src/controllers/trips/update-trip.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6bda694..792a62a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,9 +12,7 @@ jobs: steps: - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: '20.x' cache: 'npm' @@ -33,25 +31,22 @@ jobs: key: ${{ secrets.SSH_KEY }} passphrase: ${{ secrets.SSH_PASSPHRASE }} script: | - # Create a backup directory if it doesn't exist - mkdir -p /home/wooster/backend/backup + cd /home/wooster/backend + + # Create backup directory + mkdir -p backup # Stop the server - pm2 stop wooster-server || true + pm2 stop backend || true - # Backup current version (just in case) - timestamp=$(date +%Y%m%d_%H%M%S) - tar -czf /home/wooster/backend/backup/backup_${timestamp}.tar.gz /home/wooster/backend/dist /home/wooster/backend/src || true + # Backup current version + if [ -d "dist" ]; then + timestamp=$(date +%Y%m%d_%H%M%S) + tar -czf backup/backup_${timestamp}.tar.gz dist src || true + fi - # Clean the deployment directory while preserving important files - cd /home/wooster/backend - find . -mindepth 1 -maxdepth 1 \ - ! -name 'node_modules' \ - ! -name '.env' \ - ! -name 'backup' \ - ! -name 'logs' \ - ! -name 'pm2' \ - -exec rm -rf {} + + # Clean the deployment directory + sudo rm -rf dist build src package.json package-lock.json tsconfig.json .eslintrc.* README.md - name: Deploy to server uses: appleboy/scp-action@master @@ -64,7 +59,7 @@ jobs: target: '/home/wooster/backend' strip_components: 0 - - name: Restart application + - name: Post-deploy setup uses: appleboy/ssh-action@master with: host: ${{ secrets.HOST }} @@ -74,18 +69,22 @@ jobs: script: | cd /home/wooster/backend - # Install production dependencies if needed + # Install production dependencies npm ci --production # Ensure correct permissions chmod +x dist/index.js - # Restart the application - pm2 restart wooster-server || pm2 start dist/index.js --name wooster-server + # Start/restart the application + pm2 describe backend > /dev/null + if [ $? -eq 0 ]; then + pm2 restart backend --update-env + else + pm2 start dist/index.js --name backend + fi # Save PM2 config pm2 save # Clean old backups (keep last 5) - cd backup - ls -t | tail -n +6 | xargs -r rm -- + cd backup && ls -t | tail -n +6 | xargs -r rm -- diff --git a/drizzle/20241124234923_majestic_polaris.sql b/drizzle/20241124234923_majestic_polaris.sql new file mode 100644 index 0000000..d732110 --- /dev/null +++ b/drizzle/20241124234923_majestic_polaris.sql @@ -0,0 +1,2 @@ +ALTER TABLE "trips" ADD COLUMN "title" text;--> statement-breakpoint +ALTER TABLE "trips" ADD COLUMN "description" text; \ No newline at end of file diff --git a/drizzle/meta/20241124234923_snapshot.json b/drizzle/meta/20241124234923_snapshot.json new file mode 100644 index 0000000..9eb6784 --- /dev/null +++ b/drizzle/meta/20241124234923_snapshot.json @@ -0,0 +1,442 @@ +{ + "id": "0f2fbb51-6de4-441a-bf41-7511b104231d", + "prevId": "9684a514-4a29-4adf-af43-ff747e8b819f", + "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 + } + }, + "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.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 + } + }, + "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 115eb52..a58c12e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1732180743909, "tag": "20241121091903_nostalgic_sabra", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1732492163282, + "tag": "20241124234923_majestic_polaris", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/controllers/trips/update-trip.ts b/src/controllers/trips/update-trip.ts new file mode 100644 index 0000000..3eb1e71 --- /dev/null +++ b/src/controllers/trips/update-trip.ts @@ -0,0 +1,41 @@ +import { fetchTripFromDB, updateTripInDB } from '../../services/trip-service'; +import { Request, Response } from 'express'; + +export const handleUpdateTrip = async ( + req: Request< + { tripId: string }, + object, + { startDate?: string; title?: string; description?: string } + >, + res: Response, +) => { + const { tripId } = req.params; + const { startDate, title, description } = req.body; + const userId = req.user!.id; + + // Validate input + if (!startDate && !title && !description) { + return res.status(400).json({ + error: + 'At least one field (startDate, title, or description) must be provided for update.', + }); + } + + // Check if the trip exists and belongs to the authenticated user + const trip = await fetchTripFromDB(tripId, userId); + if (!trip) { + return res.status(404).json({ error: 'Trip not found' }); + } + + // Update the trip with the provided fields + const updatedTrip = await updateTripInDB(tripId, { + startDate, + title, + description, + }); + + return res.status(200).json({ + message: 'Trip updated successfully', + trip: updatedTrip, + }); +}; diff --git a/src/db/tables/trips.ts b/src/db/tables/trips.ts index 84ef258..6305cab 100644 --- a/src/db/tables/trips.ts +++ b/src/db/tables/trips.ts @@ -1,4 +1,11 @@ -import { pgTable, bigint, timestamp, uuid, serial } from 'drizzle-orm/pg-core'; +import { + pgTable, + bigint, + timestamp, + uuid, + serial, + text, +} from 'drizzle-orm/pg-core'; export const trips = pgTable('trips', { tripId: serial('trip_id').primaryKey(), @@ -9,4 +16,6 @@ export const trips = pgTable('trips', { .defaultNow() .notNull(), numDays: bigint('num_days', { mode: 'number' }), + title: text('title'), + description: text('description'), }); diff --git a/src/routes/routes.ts b/src/routes/routes.ts index a9223df..13b11dc 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -21,6 +21,7 @@ import { import { handleGetTrip } from '../controllers/trips/get-trip'; import { handleGetDestinationActivities } from '../controllers/destinations/get-destination-activities'; import { handleSearchDestinations } from '../controllers/destinations/search-destinations'; +import { handleUpdateTrip } from '../controllers/trips/update-trip'; const router = express.Router(); @@ -60,6 +61,7 @@ router.delete( router.get('/trips', requireAuth, handleGetTrips); router.get('/trips/:id', requireAuth, handleGetTrip); router.post('/trips', llmLimiter, requireAuth, handleAddTrip); +router.put('/trips/:tripId', requireAuth, handleUpdateTrip); router.delete('/trips/:tripId', requireAuth, handleDeleteTrip); export default router; diff --git a/src/services/trip-service/db-operations.ts b/src/services/trip-service/db-operations.ts index 81aa2b2..60315c4 100644 --- a/src/services/trip-service/db-operations.ts +++ b/src/services/trip-service/db-operations.ts @@ -21,6 +21,8 @@ export const fetchTripsFromDB = (userId: string) => destinationId: trips.destinationId, startDate: trips.startDate, numDays: trips.numDays, + title: trips.title, + description: trips.description, itineraryDays: itineraryDays.dayNumber, activities: { activityId: activities.activityId, @@ -149,6 +151,8 @@ export const fetchTripFromDB = (tripId: string, userId: string) => destinationId: trips.destinationId, startDate: trips.startDate, numDays: trips.numDays, + title: trips.title, + description: trips.description, itineraryDays: itineraryDays.dayNumber, activities: { activityId: activities.activityId, @@ -216,6 +220,59 @@ export const fetchTripFromDB = (tripId: string, userId: string) => { context: { tripId, userId } }, ); +interface UpdateFields { + startDate?: Date; + title?: string; + description?: string; +} + +export const updateTripInDB = async ( + tripId: string, + updates: { startDate?: string; title?: string; description?: string }, +) => { + return executeDbOperation( + async () => { + const parsedTripId = parseInt(tripId, 10); + if (isNaN(parsedTripId)) { + throw createDBQueryError('Invalid trip ID', { tripId }); + } + + const updateFields: UpdateFields = {}; + if (updates.startDate) { + updateFields.startDate = new Date(updates.startDate); + } + if (updates.title) { + updateFields.title = updates.title; + } + if (updates.description) { + updateFields.description = updates.description; + } + + const [updatedTrip] = await db + .update(trips) + .set(updateFields) + .where(eq(trips.tripId, parsedTripId)) + .returning({ + tripId: trips.tripId, + startDate: trips.startDate, + title: trips.title, + description: trips.description, + }); + + if (!updatedTrip) { + throw createDBNotFoundError(`No trip found with ID ${tripId}`, { + tripId, + }); + } + + logger.info({ tripId, updates }, 'Trip updated successfully'); + return updatedTrip; + }, + 'Error updating trip', + { context: { tripId, updates } }, + ); +}; + // Helper function to create trip in database export async function createTripInDB( userId: string, diff --git a/src/types/db-types.ts b/src/types/db-types.ts index 4d86aaf..b672e09 100644 --- a/src/types/db-types.ts +++ b/src/types/db-types.ts @@ -32,6 +32,8 @@ export interface TripDBRow { startDate: Date | null; // Allow null numDays: number | null; // Allow null itineraryDays: number | null; // Allow null + title: string | null; + description: string | null; activities: { activityId: number | null; activityName: string | null; diff --git a/src/utils/reshape-trip-data-drizzle.ts b/src/utils/reshape-trip-data-drizzle.ts index 4578ad3..f285390 100644 --- a/src/utils/reshape-trip-data-drizzle.ts +++ b/src/utils/reshape-trip-data-drizzle.ts @@ -12,6 +12,8 @@ function reshapeTripData(dbData: TripDBRow[]) { tripId: row.tripId.toString(), startDate: row.startDate, numDays: row.numDays, + description: row.description === 'NULL' ? null : row.description, + title: row.title === 'NULL' ? null : row.title, destination: { destinationId: row.destination?.destinationId, destinationName: From 9394f273ebe0c59bbc44f149c039062a9607586d Mon Sep 17 00:00:00 2001 From: Joshua Tuddenham Date: Tue, 26 Nov 2024 06:31:05 +0000 Subject: [PATCH 2/4] test: added PUT trip test --- src/controllers/trips/update-trip.ts | 2 +- ...tinations.test.ts => destinations.test.ts} | 0 src/tests/integration/trips.test.ts | 43 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) rename src/tests/integration/{bdestinations.test.ts => destinations.test.ts} (100%) diff --git a/src/controllers/trips/update-trip.ts b/src/controllers/trips/update-trip.ts index 3eb1e71..14b8ad7 100644 --- a/src/controllers/trips/update-trip.ts +++ b/src/controllers/trips/update-trip.ts @@ -34,7 +34,7 @@ export const handleUpdateTrip = async ( description, }); - return res.status(200).json({ + return res.status(201).json({ message: 'Trip updated successfully', trip: updatedTrip, }); diff --git a/src/tests/integration/bdestinations.test.ts b/src/tests/integration/destinations.test.ts similarity index 100% rename from src/tests/integration/bdestinations.test.ts rename to src/tests/integration/destinations.test.ts diff --git a/src/tests/integration/trips.test.ts b/src/tests/integration/trips.test.ts index be7bd3c..338e816 100644 --- a/src/tests/integration/trips.test.ts +++ b/src/tests/integration/trips.test.ts @@ -166,6 +166,49 @@ describe('Trips API', () => { expect(verifyResponse.body.trip).toBeDefined(); }); + it('can update a trip', async () => { + setLLMResponse([ + { type: 'success', dataType: 'destination', location: 'tokyo' }, + { type: 'success', dataType: 'trip', location: 'tokyo' }, + ]); + + const newTripData = { + days: 2, + location: 'Tokyo', + startDate: '2024-12-25', + selectedCategories: ['Cultural', 'Food & Drink'], + }; + + const response = await api + .post('/api/trips') + .set('Authorization', authHeader) + .send(newTripData) + .expect(201); + + // Add a verification GET to make sure it really worked + const tripId = response.body.trip.tripId; + + const updatedTrip = { + startDate: '2024-12-26', + title: 'My trip', + description: 'What a trip', + }; + + const newTrip = await api + .put(`/api/trips/${tripId}`) + .set('Authorization', authHeader) + .send(updatedTrip) + .expect(201); + + expect(newTrip.body.message).toBe('Trip updated successfully'); + expect(newTrip.body.trip).toHaveProperty('title', 'My trip'); + expect(newTrip.body.trip).toHaveProperty('description', 'What a trip'); + expect(newTrip.body.trip).toHaveProperty( + 'startDate', + '2024-12-26T00:00:00.000Z', + ); + }); + it('creates a trip using an existing destination', async () => { setLLMResponse([ { type: 'success', dataType: 'destination', location: 'paris' }, From 256e7d628f53101c3a6db3c1b135357a3de2a54b Mon Sep 17 00:00:00 2001 From: Joshua Tuddenham Date: Tue, 26 Nov 2024 07:01:18 +0000 Subject: [PATCH 3/4] feat: added status field to trips, updated PUT test to cover status updates --- drizzle/20241126065951_odd_reavers.sql | 1 + drizzle/meta/20241126065951_snapshot.json | 449 +++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/controllers/trips/update-trip.ts | 19 +- src/db/tables/trips.ts | 1 + src/services/trip-service/db-operations.ts | 15 +- src/tests/integration/trips.test.ts | 3 +- src/types/db-types.ts | 12 + src/utils/reshape-trip-data-drizzle.ts | 1 + 9 files changed, 503 insertions(+), 5 deletions(-) create mode 100644 drizzle/20241126065951_odd_reavers.sql create mode 100644 drizzle/meta/20241126065951_snapshot.json diff --git a/drizzle/20241126065951_odd_reavers.sql b/drizzle/20241126065951_odd_reavers.sql new file mode 100644 index 0000000..3af4ae4 --- /dev/null +++ b/drizzle/20241126065951_odd_reavers.sql @@ -0,0 +1 @@ +ALTER TABLE "trips" ADD COLUMN "status" text DEFAULT 'PLANNING' NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/20241126065951_snapshot.json b/drizzle/meta/20241126065951_snapshot.json new file mode 100644 index 0000000..8377b09 --- /dev/null +++ b/drizzle/meta/20241126065951_snapshot.json @@ -0,0 +1,449 @@ +{ + "id": "2e4f1ed2-256c-42bd-bd35-ef35ef53633d", + "prevId": "0f2fbb51-6de4-441a-bf41-7511b104231d", + "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 + } + }, + "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.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 a58c12e..bb5c93f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1732492163282, "tag": "20241124234923_majestic_polaris", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1732604391616, + "tag": "20241126065951_odd_reavers", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/controllers/trips/update-trip.ts b/src/controllers/trips/update-trip.ts index 14b8ad7..69acf4a 100644 --- a/src/controllers/trips/update-trip.ts +++ b/src/controllers/trips/update-trip.ts @@ -5,24 +5,36 @@ export const handleUpdateTrip = async ( req: Request< { tripId: string }, object, - { startDate?: string; title?: string; description?: string } + { + startDate?: string; + title?: string; + description?: string; + status?: 'PLANNING' | 'BOOKED' | 'COMPLETED'; + } >, res: Response, ) => { const { tripId } = req.params; - const { startDate, title, description } = req.body; + const { startDate, title, description, status } = req.body; const userId = req.user!.id; // Validate input if (!startDate && !title && !description) { return res.status(400).json({ error: - 'At least one field (startDate, title, or description) must be provided for update.', + 'At least one field (startDate, title, description, or status) must be provided for update.', + }); + } + + if (status && !['PLANNING', 'BOOKED', 'COMPLETED'].includes(status)) { + return res.status(400).json({ + error: 'Invalid status value', }); } // Check if the trip exists and belongs to the authenticated user const trip = await fetchTripFromDB(tripId, userId); + if (!trip) { return res.status(404).json({ error: 'Trip not found' }); } @@ -32,6 +44,7 @@ export const handleUpdateTrip = async ( startDate, title, description, + status, }); return res.status(201).json({ diff --git a/src/db/tables/trips.ts b/src/db/tables/trips.ts index 6305cab..f31aced 100644 --- a/src/db/tables/trips.ts +++ b/src/db/tables/trips.ts @@ -18,4 +18,5 @@ export const trips = pgTable('trips', { numDays: bigint('num_days', { mode: 'number' }), title: text('title'), description: text('description'), + status: text('status').default('PLANNING').notNull(), }); diff --git a/src/services/trip-service/db-operations.ts b/src/services/trip-service/db-operations.ts index 60315c4..69c6e91 100644 --- a/src/services/trip-service/db-operations.ts +++ b/src/services/trip-service/db-operations.ts @@ -23,6 +23,7 @@ export const fetchTripsFromDB = (userId: string) => numDays: trips.numDays, title: trips.title, description: trips.description, + status: trips.status, itineraryDays: itineraryDays.dayNumber, activities: { activityId: activities.activityId, @@ -153,6 +154,7 @@ export const fetchTripFromDB = (tripId: string, userId: string) => numDays: trips.numDays, title: trips.title, description: trips.description, + status: trips.status, itineraryDays: itineraryDays.dayNumber, activities: { activityId: activities.activityId, @@ -224,11 +226,17 @@ interface UpdateFields { startDate?: Date; title?: string; description?: string; + status?: string; } export const updateTripInDB = async ( tripId: string, - updates: { startDate?: string; title?: string; description?: string }, + updates: { + startDate?: string; + title?: string; + description?: string; + status?: string; + }, ) => { return executeDbOperation( async () => { @@ -248,6 +256,10 @@ export const updateTripInDB = async ( updateFields.description = updates.description; } + if (updates.status) { + updateFields.status = updates.status; + } + const [updatedTrip] = await db .update(trips) .set(updateFields) @@ -257,6 +269,7 @@ export const updateTripInDB = async ( startDate: trips.startDate, title: trips.title, description: trips.description, + status: trips.status, }); if (!updatedTrip) { diff --git a/src/tests/integration/trips.test.ts b/src/tests/integration/trips.test.ts index 338e816..44db779 100644 --- a/src/tests/integration/trips.test.ts +++ b/src/tests/integration/trips.test.ts @@ -185,13 +185,13 @@ describe('Trips API', () => { .send(newTripData) .expect(201); - // Add a verification GET to make sure it really worked const tripId = response.body.trip.tripId; const updatedTrip = { startDate: '2024-12-26', title: 'My trip', description: 'What a trip', + status: 'BOOKED', }; const newTrip = await api @@ -203,6 +203,7 @@ describe('Trips API', () => { expect(newTrip.body.message).toBe('Trip updated successfully'); expect(newTrip.body.trip).toHaveProperty('title', 'My trip'); expect(newTrip.body.trip).toHaveProperty('description', 'What a trip'); + expect(newTrip.body.trip).toHaveProperty('status', 'BOOKED'); expect(newTrip.body.trip).toHaveProperty( 'startDate', '2024-12-26T00:00:00.000Z', diff --git a/src/types/db-types.ts b/src/types/db-types.ts index b672e09..edd6e88 100644 --- a/src/types/db-types.ts +++ b/src/types/db-types.ts @@ -32,6 +32,7 @@ export interface TripDBRow { startDate: Date | null; // Allow null numDays: number | null; // Allow null itineraryDays: number | null; // Allow null + status: string; title: string | null; description: string | null; activities: { @@ -54,3 +55,14 @@ export interface DBItineraryDay { day: number; activities: DBActivity[]; } + +export interface TripTableRow { + tripId: number; + startDate: Date | null; + numDays: number | null; + title: string | null; + description: string | null; + status: TripStatus; +} + +export type TripStatus = 'PLANNING' | 'BOOKED' | 'COMPLETED'; diff --git a/src/utils/reshape-trip-data-drizzle.ts b/src/utils/reshape-trip-data-drizzle.ts index f285390..1881cc2 100644 --- a/src/utils/reshape-trip-data-drizzle.ts +++ b/src/utils/reshape-trip-data-drizzle.ts @@ -11,6 +11,7 @@ function reshapeTripData(dbData: TripDBRow[]) { tripsMap[row.tripId] = { tripId: row.tripId.toString(), startDate: row.startDate, + status: row.status, numDays: row.numDays, description: row.description === 'NULL' ? null : row.description, title: row.title === 'NULL' ? null : row.title, From 435e4e8557e8a28ed5df510b9153638af61e39ce Mon Sep 17 00:00:00 2001 From: Joshua Tuddenham Date: Wed, 27 Nov 2024 06:47:39 +0000 Subject: [PATCH 4/4] feat: added share trip route, added get trip route. test: added integration test for both --- drizzle/20241126124900_strong_tyger_tiger.sql | 15 + drizzle/meta/20241126124900_snapshot.json | 521 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + jest.config.ts | 3 + package-lock.json | 19 + package.json | 1 + src/controllers/trips/get-shared-trip.ts | 44 ++ src/controllers/trips/share.ts | 33 ++ src/db/tables/index.ts | 1 + src/db/tables/shared_trips.ts | 15 + src/routes/routes.ts | 6 + src/services/trip-service/db-operations.ts | 8 +- src/services/trip-service/index.ts | 1 + src/services/trip-service/share.ts | 32 ++ src/tests/integration/trips.test.ts | 51 ++ tsconfig.test.json | 4 +- 16 files changed, 758 insertions(+), 3 deletions(-) create mode 100644 drizzle/20241126124900_strong_tyger_tiger.sql create mode 100644 drizzle/meta/20241126124900_snapshot.json create mode 100644 src/controllers/trips/get-shared-trip.ts create mode 100644 src/controllers/trips/share.ts create mode 100644 src/db/tables/shared_trips.ts create mode 100644 src/services/trip-service/share.ts diff --git a/drizzle/20241126124900_strong_tyger_tiger.sql b/drizzle/20241126124900_strong_tyger_tiger.sql new file mode 100644 index 0000000..13bbb92 --- /dev/null +++ b/drizzle/20241126124900_strong_tyger_tiger.sql @@ -0,0 +1,15 @@ +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 +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/meta/20241126124900_snapshot.json b/drizzle/meta/20241126124900_snapshot.json new file mode 100644 index 0000000..7822d76 --- /dev/null +++ b/drizzle/meta/20241126124900_snapshot.json @@ -0,0 +1,521 @@ +{ + "id": "84cba25e-3f3e-46ba-ad5f-cb4e5871c1b8", + "prevId": "2e4f1ed2-256c-42bd-bd35-ef35ef53633d", + "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 + } + }, + "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.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 + }, + "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 + } + }, + "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 bb5c93f..391d49f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1732604391616, "tag": "20241126065951_odd_reavers", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1732625340355, + "tag": "20241126124900_strong_tyger_tiger", + "breakpoints": true } ] } \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts index e5aa9c4..3a91445 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -21,9 +21,12 @@ const config: Config = { 'ts-jest', { tsconfig: 'tsconfig.test.json', + useESM: true, }, ], }, + transformIgnorePatterns: ['node_modules/(?!(nanoid)/)'], + extensionsToTreatAsEsm: ['.ts'], }; export default config; diff --git a/package-lock.json b/package-lock.json index a8df1d0..62fa41b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "express": "^4.21.0", "express-async-errors": "^3.1.1", "express-rate-limit": "^7.4.1", + "nanoid": "^5.0.9", "pg": "^8.13.1", "pg-mem": "^3.0.3", "pino": "^9.5.0", @@ -7326,6 +7327,24 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/nanoid": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", + "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", diff --git a/package.json b/package.json index 98b4b4e..6afff26 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "express": "^4.21.0", "express-async-errors": "^3.1.1", "express-rate-limit": "^7.4.1", + "nanoid": "^5.0.9", "pg": "^8.13.1", "pg-mem": "^3.0.3", "pino": "^9.5.0", diff --git a/src/controllers/trips/get-shared-trip.ts b/src/controllers/trips/get-shared-trip.ts new file mode 100644 index 0000000..2c8e4d6 --- /dev/null +++ b/src/controllers/trips/get-shared-trip.ts @@ -0,0 +1,44 @@ +import { Request, Response } from 'express'; +import { + createDBNotFoundError, + createValidationError, +} from '../../utils/error-handlers'; +import { db, sharedTrips } from '../../db'; +import { eq } from 'drizzle-orm'; +import { fetchTripFromDB } from '../../services/trip-service'; + +export const handleGetSharedTrip = async ( + req: Request<{ shareCode: string }>, + res: Response, +) => { + const { shareCode } = req.params; + + if (!shareCode) { + throw createValidationError('Share code is required'); + } + + // First get the shared trip record + const sharedTrip = await db.query.sharedTrips.findFirst({ + where: eq(sharedTrips.shareCode, shareCode), + columns: { + tripId: true, + expiresAt: true, + }, + }); + + if (!sharedTrip) { + throw createDBNotFoundError('Shared trip not found'); + } + + if (sharedTrip.expiresAt && new Date(sharedTrip.expiresAt) < new Date()) { + throw createValidationError('Share link has expired'); + } + + // Use the existing fetch function without userId + const trip = await fetchTripFromDB(sharedTrip.tripId.toString()); + + return res.status(200).json({ + message: 'Shared trip fetched successfully', + trip: trip[0], + }); +}; diff --git a/src/controllers/trips/share.ts b/src/controllers/trips/share.ts new file mode 100644 index 0000000..6021818 --- /dev/null +++ b/src/controllers/trips/share.ts @@ -0,0 +1,33 @@ +import { Request, Response } from 'express'; +import { createShareLink, fetchTripFromDB } from '../../services/trip-service'; +import { + createDBNotFoundError, + createValidationError, +} from '../../utils/error-handlers'; + +export const handleCreateShareLink = async ( + req: Request<{ tripId: string }>, + res: Response, +) => { + const { tripId } = req.params; + const userId = req.user!.id; + + // Validate and parse tripId + const parsedTripId = parseInt(tripId, 10); + if (isNaN(parsedTripId)) { + throw createValidationError('Invalid trip ID'); + } + + const trip = await fetchTripFromDB(tripId, userId); // uses string + + if (!trip) { + throw createDBNotFoundError('Trip not found'); + } + + const sharedTrip = await createShareLink(parsedTripId, userId); // uses number + + return res.status(201).json({ + message: 'Share link created successfully', + shareCode: sharedTrip.shareCode, + }); +}; diff --git a/src/db/tables/index.ts b/src/db/tables/index.ts index fae5e1d..70b6d7f 100644 --- a/src/db/tables/index.ts +++ b/src/db/tables/index.ts @@ -3,3 +3,4 @@ export { destinations } from './destinations'; export { itineraryDays } from './itinerary_days'; export { savedDestinations } from './saved_destinations'; export { trips } from './trips'; +export { sharedTrips } from './shared_trips'; diff --git a/src/db/tables/shared_trips.ts b/src/db/tables/shared_trips.ts new file mode 100644 index 0000000..86e2d38 --- /dev/null +++ b/src/db/tables/shared_trips.ts @@ -0,0 +1,15 @@ +import { pgTable, serial, uuid, timestamp, text } from 'drizzle-orm/pg-core'; +import { trips } from './trips'; + +export const sharedTrips = pgTable('shared_trips', { + id: serial('id').primaryKey(), + userId: uuid('user_id').notNull(), + tripId: serial('trip_id') + .notNull() + .references(() => trips.tripId, { onDelete: 'cascade' }), + shareCode: text('share_code').notNull().unique(), + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }), +}); diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 13b11dc..b623fc6 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -22,6 +22,8 @@ import { handleGetTrip } from '../controllers/trips/get-trip'; import { handleGetDestinationActivities } from '../controllers/destinations/get-destination-activities'; import { handleSearchDestinations } from '../controllers/destinations/search-destinations'; import { handleUpdateTrip } from '../controllers/trips/update-trip'; +import { handleCreateShareLink } from '../controllers/trips/share'; +import { handleGetSharedTrip } from '../controllers/trips/get-shared-trip'; const router = express.Router(); @@ -64,4 +66,8 @@ router.post('/trips', llmLimiter, requireAuth, handleAddTrip); router.put('/trips/:tripId', requireAuth, handleUpdateTrip); router.delete('/trips/:tripId', requireAuth, handleDeleteTrip); +// Share routes +router.post('/trips/:tripId/share', requireAuth, handleCreateShareLink); +router.get('/shared/:shareCode', handleGetSharedTrip); + export default router; diff --git a/src/services/trip-service/db-operations.ts b/src/services/trip-service/db-operations.ts index 69c6e91..b340fb8 100644 --- a/src/services/trip-service/db-operations.ts +++ b/src/services/trip-service/db-operations.ts @@ -135,7 +135,7 @@ export const deleteTripById = (tripId: number) => { context: { tripId } }, ); -export const fetchTripFromDB = (tripId: string, userId: string) => +export const fetchTripFromDB = (tripId: string, userId?: string) => executeDbOperation( async () => { const parsedTripId = parseInt(tripId, 10); @@ -146,6 +146,10 @@ export const fetchTripFromDB = (tripId: string, userId: string) => throw createDBQueryError(errorMessage, { tripId }); } + const whereConditions = userId + ? and(eq(trips.tripId, parsedTripId), eq(trips.userId, userId)) + : eq(trips.tripId, parsedTripId); + const tripData = await db .select({ tripId: trips.tripId, @@ -195,7 +199,7 @@ export const fetchTripFromDB = (tripId: string, userId: string) => }, }) .from(trips) - .where(and(eq(trips.tripId, parsedTripId), eq(trips.userId, userId))) + .where(whereConditions) .leftJoin( destinations, eq(destinations.destinationId, trips.destinationId), diff --git a/src/services/trip-service/index.ts b/src/services/trip-service/index.ts index 3cb51dd..dd65505 100644 --- a/src/services/trip-service/index.ts +++ b/src/services/trip-service/index.ts @@ -1,2 +1,3 @@ export * from './db-operations'; export * from './validators'; +export * from './share'; diff --git a/src/services/trip-service/share.ts b/src/services/trip-service/share.ts new file mode 100644 index 0000000..aa8385f --- /dev/null +++ b/src/services/trip-service/share.ts @@ -0,0 +1,32 @@ +import { db, sharedTrips } from '../../db'; +import { executeDbOperation } from '../../utils/db-utils'; +import { logger } from '../../utils/logger'; + +import { randomUUID } from 'crypto'; + +export const createShareLink = (tripId: number, userId: string) => + executeDbOperation( + async () => { + const shareCode = randomUUID().slice(0, 10); + + const [sharedTrip] = await db + .insert(sharedTrips) + .values({ + tripId, + userId, + shareCode, + }) + .returning({ + id: sharedTrips.id, + shareCode: sharedTrips.shareCode, + }); + + logger.info( + { tripId, userId, shareCode }, + 'Created share link successfully', + ); + return sharedTrip; + }, + 'Failed to create share link', + { context: { tripId, userId } }, + ); diff --git a/src/tests/integration/trips.test.ts b/src/tests/integration/trips.test.ts index 44db779..00ffa52 100644 --- a/src/tests/integration/trips.test.ts +++ b/src/tests/integration/trips.test.ts @@ -250,4 +250,55 @@ describe('Trips API', () => { expect(destinationResult[0].count).toBe(1); }); + + it('can share a trip', async () => { + setLLMResponse([ + { type: 'success', dataType: 'destination', location: 'tokyo' }, + { type: 'success', dataType: 'trip', location: 'tokyo' }, + ]); + + const newTripData = { + days: 2, + location: 'Tokyo', + startDate: '2024-12-25', + selectedCategories: ['Cultural', 'Food & Drink'], + }; + + const response = await api + .post('/api/trips') + .set('Authorization', authHeader) + .send(newTripData) + .expect(201); + + const tripId = response.body.trip.tripId; + + const sharedTrip = await api + .post(`/api/trips/${tripId}/share`) + .set('Authorization', authHeader) + .expect(201); + + expect(sharedTrip.body.message).toBe('Share link created successfully'); + + const shareCode = sharedTrip.body.shareCode; + + const unprotectedTrip = await api + .get(`/api/shared/${shareCode}`) + .expect(200); + + expect(unprotectedTrip.body).toMatchObject({ + message: 'Shared trip fetched successfully', + trip: { + tripId: expect.any(String), + startDate: expect.any(String), + numDays: expect.any(Number), + destination: expect.any(Object), + itinerary: expect.arrayContaining([ + expect.objectContaining({ + day: expect.any(Number), + activities: expect.any(Array), + }), + ]), + }, + }); + }); }); diff --git a/tsconfig.test.json b/tsconfig.test.json index 00549d9..4a9f512 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -2,7 +2,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "rootDir": ".", - "types": ["jest", "node"] + "types": ["jest", "node"], + "module": "ESNext", + "moduleResolution": "node" }, "include": ["src/**/*", "tests/**/*"], "exclude": ["node_modules", "dist"]