From ff9a909000043220f8a61b02ca3eadca7822277a Mon Sep 17 00:00:00 2001 From: Morais-C Date: Fri, 15 Nov 2024 10:39:55 +0000 Subject: [PATCH 1/4] feat(venues): add basic venue management - Add Venue model to Prisma schema with basic fields - name, description, address - contact info as JSON (phone, email, website) - images array for venue photos - timestamps - Implement complete CRUD operations for venues - Create venue with validation - Read venue(s) with pagination/sorting - Update venue with partial fields - Delete venue with proper error handling - Add validation using Zod schemas - Input validation for all fields - Query parameter validation - Contact info structure validation - Create venue API endpoints - GET /api/venues - GET /api/venues/:id - POST /api/venues - PATCH /api/venues/:id - PUT /api/venues/:id - DELETE /api/venues/:id - Add test data - Seed three example venues - Include realistic contact info - Add sample image URLs - Update documentation - Add venue endpoints to Postman collection - Document request/response formats - Include example payloads Part of Event Zone Search feature - Step 1/4 --- .vscode/settings.json | 15 +- .../2024.11.15_Approach_to_EventZoneSearch.md | 136 +++++++++++ postman/README.md | 100 ++++++++ .../v52-tier3-team-34.postman_collection.json | 221 ++++++++++++++++++ .../migration.sql | 13 ++ server/prisma/schema.prisma | 11 + server/prisma/seed.ts | 108 +++++++-- server/src/controllers/venue.controller.ts | 206 ++++++++++++++++ server/src/routes/index.ts | 2 + server/src/routes/venue.routes.ts | 42 ++++ server/src/services/venue.service.ts | 132 +++++++++++ server/src/types/venue.types.ts | 58 +++++ server/tsconfig.json | 119 +--------- 13 files changed, 1031 insertions(+), 132 deletions(-) create mode 100644 docs/2024.11.15_Approach_to_EventZoneSearch.md create mode 100644 server/prisma/migrations/20241115101749_add_venue_model/migration.sql create mode 100644 server/src/controllers/venue.controller.ts create mode 100644 server/src/routes/venue.routes.ts create mode 100644 server/src/services/venue.service.ts create mode 100644 server/src/types/venue.types.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 0aa52be..227a22d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,22 @@ { "cSpell.words": [ + "Asare", + "Calle", + "Chingu", "codepaths", + "Cristiano", + "Damilola", "datasource", + "geospatial", + "Honvedo", + "Hoxton", + "Morais", + "Oldrini", + "Oshinowo", "previewlanguage", "respawn", - "tsbuildinfo" + "tablao", + "tsbuildinfo", + "Valente" ] } diff --git a/docs/2024.11.15_Approach_to_EventZoneSearch.md b/docs/2024.11.15_Approach_to_EventZoneSearch.md new file mode 100644 index 0000000..cd5d3ef --- /dev/null +++ b/docs/2024.11.15_Approach_to_EventZoneSearch.md @@ -0,0 +1,136 @@ +# Event Zone Search Feature Implementation Strategy + +## Overview +Implementation of geospatial search functionality for live music events, including venue management and location-based queries. + +## Implementation Steps + +### Step 1: Add Venue Model & Basic CRUD +1. **Code Changes** + - Update Prisma schema with Venue model + - Create venue types/validation schemas + - Implement Venue controller/service/routes + - Follow existing CRUD pattern + +2. **Testing** + - Add venue endpoints to Postman collection + - Create example requests/responses + - Test all CRUD operations + +3. **Documentation** + - Document new venue endpoints + - Update API documentation + - Add request/response examples + +4. **Commit** + - Message: "feat: add basic venue management" + - List schema changes + - List new endpoints + +### Step 2: Enhance Venue with Geospatial +1. **Code Changes** + - Add coordinates to Venue model + - Update validation schemas + - Add GeoJSON response types + - Implement location search + +2. **Testing** + - Add geospatial queries to Postman + - Test coordinate validation + - Test GeoJSON responses + +3. **Documentation** + - Document geospatial features + - Add GeoJSON examples + - Update schema documentation + +4. **Commit** + - Message: "feat: add geospatial capabilities to venues" + - Detail coordinate system + - List new queries + +### Step 3: Connect Events & Venues +1. **Code Changes** + - Update Event model with venue relation + - Enhance Event controller + - Update event creation flow + - Modify event responses + +2. **Testing** + - Update event endpoints in Postman + - Test venue relationships + - Verify data integrity + +3. **Documentation** + - Update event endpoint docs + - Document relationship rules + - Add new response formats + +4. **Commit** + - Message: "feat: link events with venues" + - List relationship changes + - Document foreign keys + +### Step 4: Implement Zone Search +1. **Code Changes** + - Add geospatial query endpoint + - Implement GeoJSON responses + - Add distance calculations + - Create zone search logic + +2. **Testing** + - Add zone search to Postman + - Test radius searches + - Verify distance calculations + +3. **Documentation** + - Document zone search endpoint + - Add GeoJSON response examples + - Include query parameters + +4. **Commit** + - Message: "feat: add event zone search" + - Detail search parameters + - List response format + +## Final Deliverables + +### Pull Request +- Title: "Feature: Event Zone Search Implementation" +- Summary of all four steps +- Reference to project requirements +- Testing instructions +- Migration steps + +### Documentation Updates +- README.md updates +- API documentation +- Schema changes +- New features description +- Environment variables +- Migration guide + +### Testing Package +- Complete Postman collection +- Example data +- Test scenarios +- Environment configurations + +## Notes +- Maintain backward compatibility +- Follow existing code patterns +- Keep commits atomic +- Update documentation inline +- Test thoroughly at each step + +## Environment Considerations +- PostgreSQL with PostGIS +- GeoJSON support +- Coordinate system standards +- Distance calculation methods + +## Migration Notes +- Database schema changes +- New dependencies +- Configuration updates +- Backward compatibility diff --git a/postman/README.md b/postman/README.md index ef9f585..a3887c8 100644 --- a/postman/README.md +++ b/postman/README.md @@ -444,6 +444,106 @@ Current variables: } ``` +### Venues + +#### List Venues +- **Endpoint**: GET `/api/venues` +- **Query Parameters**: + - page: number (optional, default: 1) + - limit: number (optional, default: 10, max: 100) + - orderBy: string (optional, values: 'name', 'createdAt') + - order: string (optional, values: 'asc', 'desc') +- **Success Response** (200): + ```json + { + "status": "success", + "data": [ + { + "id": 1, + "name": "Blue Note Jazz Club", + "description": "Historic jazz venue featuring nightly live performances...", + "address": "131 W 3rd St, New York, NY 10012", + "contact": { + "phone": "+1-212-475-8592", + "email": "info@bluenote.net", + "website": "https://www.bluenotejazz.com" + }, + "images": [ + "https://example.com/venues/bluenote1.jpg", + "https://example.com/venues/bluenote2.jpg" + ], + "createdAt": "2024-03-20T10:00:00.000Z", + "updatedAt": "2024-03-20T10:00:00.000Z" + } + ], + "pagination": { + "currentPage": 1, + "totalPages": 1, + "totalItems": 3, + "itemsPerPage": 10, + "hasNextPage": false, + "hasPreviousPage": false + }, + "timestamp": "2024-03-20T10:00:00.000Z" + } + ``` + +#### Get Venue by ID +- **Endpoint**: GET `/api/venues/:id` +- **Parameters**: + - id: number (positive integer) +- **Success Response** (200): Same structure as single venue in list response +- **Error Response** (404): + ```json + { + "status": "error", + "message": "Venue not found", + "timestamp": "2024-03-20T10:00:00.000Z" + } + ``` + +#### Create Venue +- **Endpoint**: POST `/api/venues` +- **Request Body**: + ```json + { + "name": "New Venue", + "description": "Venue description", + "address": "Venue address", + "contact": { + "phone": "+1-555-0123", + "email": "contact@venue.com", + "website": "https://www.venue.com" + }, + "images": ["https://example.com/venue1.jpg"] + } + ``` +- **Success Response** (201): Same structure as Get Venue response + +#### Update Venue +- **Endpoint**: PATCH `/api/venues/:id` +- **Parameters**: id (number) +- **Request Body** (all fields optional): Same structure as Create +- **Success Response** (200): Same structure as Get Venue response + +#### Replace Venue +- **Endpoint**: PUT `/api/venues/:id` +- **Parameters**: id (number) +- **Request Body** (all fields required): Same structure as Create +- **Success Response** (200): Same structure as Get Venue response + +#### Delete Venue +- **Endpoint**: DELETE `/api/venues/:id` +- **Parameters**: id (number) +- **Success Response** (200): + ```json + { + "status": "success", + "message": "Venue deleted successfully", + "timestamp": "2024-03-20T10:00:00.000Z" + } + ``` + ## Testing Instructions ### Basic Request Testing diff --git a/postman/v52-tier3-team-34.postman_collection.json b/postman/v52-tier3-team-34.postman_collection.json index c4ab33e..514e269 100644 --- a/postman/v52-tier3-team-34.postman_collection.json +++ b/postman/v52-tier3-team-34.postman_collection.json @@ -546,6 +546,227 @@ } } ] + }, + { + "name": "Venues", + "item": [ + { + "name": "List Venues", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/venues?page=1&limit=10&orderBy=name&order=asc", + "host": ["{{baseUrl}}"], + "path": ["api", "venues"], + "query": [ + { + "key": "page", + "value": "1", + "description": "Page number (optional)", + "disabled": true + }, + { + "key": "limit", + "value": "10", + "description": "Items per page (optional)", + "disabled": true + }, + { + "key": "orderBy", + "value": "name", + "description": "Sort field (name, createdAt)", + "disabled": true + }, + { + "key": "order", + "value": "asc", + "description": "Sort order (asc, desc)", + "disabled": true + } + ] + }, + "description": "Get a list of venues with optional pagination and sorting" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/venues" + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "status": "success", + "data": [ + { + "id": 1, + "name": "Blue Note Jazz Club", + "description": "Historic jazz venue featuring nightly live performances and full bar service.", + "address": "131 W 3rd St, New York, NY 10012", + "contact": { + "phone": "+1-212-475-8592", + "email": "info@bluenote.net", + "website": "https://www.bluenotejazz.com" + }, + "images": [ + "https://example.com/venues/bluenote1.jpg", + "https://example.com/venues/bluenote2.jpg" + ], + "createdAt": "2024-03-20T10:00:00.000Z", + "updatedAt": "2024-03-20T10:00:00.000Z" + } + ], + "pagination": { + "currentPage": 1, + "totalPages": 1, + "totalItems": 3, + "itemsPerPage": 10, + "hasNextPage": false, + "hasPreviousPage": false + }, + "timestamp": "2024-03-20T10:00:00.000Z" + } + } + ] + }, + { + "name": "Get Venue by ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/venues/1", + "host": ["{{baseUrl}}"], + "path": ["api", "venues", "1"] + }, + "description": "Get a venue by its ID" + }, + "response": [ + { + "name": "Success Response", + "originalRequest": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/venues/1" + } + }, + "status": "OK", + "code": 200, + "body": { + "status": "success", + "data": { + "id": 1, + "name": "Blue Note Jazz Club", + "description": "Historic jazz venue featuring nightly live performances and full bar service.", + "address": "131 W 3rd St, New York, NY 10012", + "contact": { + "phone": "+1-212-475-8592", + "email": "info@bluenote.net", + "website": "https://www.bluenotejazz.com" + }, + "images": [ + "https://example.com/venues/bluenote1.jpg", + "https://example.com/venues/bluenote2.jpg" + ], + "createdAt": "2024-03-20T10:00:00.000Z", + "updatedAt": "2024-03-20T10:00:00.000Z" + }, + "timestamp": "2024-03-20T10:00:00.000Z" + } + } + ] + }, + { + "name": "Create Venue", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"New Test Venue\",\n \"description\": \"A great new venue for live music\",\n \"address\": \"123 Test Street, Test City\",\n \"contact\": {\n \"phone\": \"+1-555-0123\",\n \"email\": \"contact@testvenue.com\",\n \"website\": \"https://www.testvenue.com\"\n },\n \"images\": [\n \"https://example.com/venues/test1.jpg\"\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/venues", + "host": ["{{baseUrl}}"], + "path": ["api", "venues"] + }, + "description": "Create a new venue" + } + }, + { + "name": "Update Venue", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Updated Venue Name\",\n \"images\": [\n \"https://example.com/venues/updated1.jpg\",\n \"https://example.com/venues/updated2.jpg\"\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/venues/1", + "host": ["{{baseUrl}}"], + "path": ["api", "venues", "1"] + }, + "description": "Update specific fields of a venue" + } + }, + { + "name": "Replace Venue", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Completely New Venue\",\n \"description\": \"A completely updated venue description\",\n \"address\": \"456 New Street, New City\",\n \"contact\": {\n \"phone\": \"+1-555-9999\",\n \"email\": \"new@venue.com\",\n \"website\": \"https://www.newvenue.com\"\n },\n \"images\": [\n \"https://example.com/venues/new1.jpg\",\n \"https://example.com/venues/new2.jpg\"\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/venues/1", + "host": ["{{baseUrl}}"], + "path": ["api", "venues", "1"] + }, + "description": "Replace an entire venue. All fields required." + } + }, + { + "name": "Delete Venue", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/venues/1", + "host": ["{{baseUrl}}"], + "path": ["api", "venues", "1"] + }, + "description": "Delete a venue by ID" + } + } + ] } ] } diff --git a/server/prisma/migrations/20241115101749_add_venue_model/migration.sql b/server/prisma/migrations/20241115101749_add_venue_model/migration.sql new file mode 100644 index 0000000..b0e1136 --- /dev/null +++ b/server/prisma/migrations/20241115101749_add_venue_model/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "Venue" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "address" TEXT NOT NULL, + "contact" JSONB NOT NULL, + "images" TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Venue_pkey" PRIMARY KEY ("id") +); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 4e6b624..b2c516a 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -31,3 +31,14 @@ model Event { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model Venue { + id Int @id @default(autoincrement()) + name String + description String + address String + contact Json // {phone, email, website} + images String[] // Array of image URLs + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index bab88cb..8cffccf 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -1,11 +1,65 @@ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { // Clear existing data + await prisma.venue.deleteMany(); + await prisma.event.deleteMany(); await prisma.user.deleteMany(); + // Create test venues + const venues = await Promise.all([ + prisma.venue.create({ + data: { + name: "Blue Note Jazz Club", + description: "Historic jazz venue featuring nightly live performances and full bar service.", + address: "131 W 3rd St, New York, NY 10012", + contact: { + phone: "+1-212-475-8592", + email: "info@bluenote.net", + website: "https://www.bluenotejazz.com" + }, + images: [ + "https://example.com/venues/bluenote1.jpg", + "https://example.com/venues/bluenote2.jpg" + ] + } + }), + prisma.venue.create({ + data: { + name: "The Basement Bar", + description: "Intimate underground venue for live music and craft cocktails.", + address: "42 Hoxton Square, London N1 6PB", + contact: { + phone: "+44-20-7729-4121", + email: "bookings@basementbar.co.uk", + website: "https://www.thebasementbar.co.uk" + }, + images: [ + "https://example.com/venues/basement1.jpg" + ] + } + }), + prisma.venue.create({ + data: { + name: "Flamenco Casa", + description: "Authentic Spanish tablao featuring nightly flamenco shows.", + address: "Calle de los Reyes, 28015 Madrid", + contact: { + phone: "+34-91-547-2672", + email: "hola@flamencacasa.es", + website: "https://www.flamencacasa.es" + }, + images: [ + "https://example.com/venues/flamenco1.jpg", + "https://example.com/venues/flamenco2.jpg", + "https://example.com/venues/flamenco3.jpg" + ] + } + }) + ]); + // Create test users const users = await Promise.all([ // Developers @@ -14,7 +68,7 @@ async function main() { email: "john.dev@example.com", name: "John Developer", googleId: "google_dev_123", - profileImage: "https://example.com/avatars/john.jpg" + profileImage: "https://example.com/avatars/john.jpg", }, }), prisma.user.create({ @@ -22,17 +76,17 @@ async function main() { email: "jane.tech@example.com", name: "Jane Tech", googleId: "google_dev_456", - profileImage: "https://example.com/avatars/jane.jpg" + profileImage: "https://example.com/avatars/jane.jpg", }, }), - + // Designers prisma.user.create({ data: { email: "bob.design@example.com", name: "Bob Designer", googleId: "google_design_789", - profileImage: "https://example.com/avatars/bob.jpg" + profileImage: "https://example.com/avatars/bob.jpg", }, }), prisma.user.create({ @@ -40,7 +94,7 @@ async function main() { email: "alice.ux@example.com", name: "Alice UX", googleId: "google_design_101", - profileImage: "https://example.com/avatars/alice.jpg" + profileImage: "https://example.com/avatars/alice.jpg", }, }), @@ -50,7 +104,7 @@ async function main() { email: "sarah.pm@example.com", name: "Sarah Manager", googleId: "google_pm_102", - profileImage: "https://example.com/avatars/sarah.jpg" + profileImage: "https://example.com/avatars/sarah.jpg", }, }), prisma.user.create({ @@ -58,7 +112,7 @@ async function main() { email: "mike.lead@example.com", name: "Mike Team Lead", googleId: "google_pm_103", - profileImage: "https://example.com/avatars/mike.jpg" + profileImage: "https://example.com/avatars/mike.jpg", }, }), @@ -68,7 +122,7 @@ async function main() { email: "emma.marketing@example.com", name: "Emma Marketing", googleId: "google_marketing_104", - profileImage: "https://example.com/avatars/emma.jpg" + profileImage: "https://example.com/avatars/emma.jpg", }, }), prisma.user.create({ @@ -85,7 +139,7 @@ async function main() { email: "tom.qa@example.com", name: "Tom Tester", googleId: "google_qa_106", - profileImage: "https://example.com/avatars/tom.jpg" + profileImage: "https://example.com/avatars/tom.jpg", }, }), prisma.user.create({ @@ -93,25 +147,28 @@ async function main() { email: "lisa.qa@example.com", name: "Lisa QA", googleId: "google_qa_107", - profileImage: "https://example.com/avatars/lisa.jpg" + profileImage: "https://example.com/avatars/lisa.jpg", }, - }) + }), ]); - console.log(`Database has been seeded with ${users.length} users 🌱`); + console.log(`Database has been seeded with:`); + console.log(`- ${venues.length} venues`); + venues.forEach(venue => { + console.log(` - ${venue.name} (ID: ${venue.id})`); + }); + console.log(`- ${users.length} users`); users.forEach(user => { - console.log(`Created user: ${user.name} (ID: ${user.id})`); + console.log(` - ${user.name} (ID: ${user.id})`); }); - // Clear existing events - await prisma.event.deleteMany(); - // Create sample events const events = await Promise.all([ prisma.event.create({ data: { title: "Jazz Night at Blue Note", - description: "Live jazz quartet performing classic standards and original compositions. Perfect for a sophisticated evening out.", + description: + "Live jazz quartet performing classic standards and original compositions. Perfect for a sophisticated evening out.", startDate: new Date("2024-03-25T19:00:00Z"), endDate: new Date("2024-03-25T23:00:00Z"), location: "Blue Note Bar & Restaurant", @@ -121,7 +178,8 @@ async function main() { prisma.event.create({ data: { title: "Acoustic Sessions at The Old Pub", - description: "Local singer-songwriters showcase their original music in an intimate setting. Great craft beer selection available.", + description: + "Local singer-songwriters showcase their original music in an intimate setting. Great craft beer selection available.", startDate: new Date("2024-03-28T20:00:00Z"), endDate: new Date("2024-03-29T00:00:00Z"), location: "The Old Pub", @@ -131,7 +189,8 @@ async function main() { prisma.event.create({ data: { title: "Latin Night at Casa Bonita", - description: "Live salsa band and dance lessons. Featuring authentic Latin cuisine and signature cocktails.", + description: + "Live salsa band and dance lessons. Featuring authentic Latin cuisine and signature cocktails.", startDate: new Date("2024-03-30T21:00:00Z"), endDate: new Date("2024-03-31T02:00:00Z"), location: "Casa Bonita Restaurant & Bar", @@ -141,7 +200,8 @@ async function main() { prisma.event.create({ data: { title: "Rock Cover Band at Murphy's", - description: "Local favorites 'The Amplifiers' playing classic rock hits from the 70s to now. Full bar and pub menu available.", + description: + "Local favorites 'The Amplifiers' playing classic rock hits from the 70s to now. Full bar and pub menu available.", startDate: new Date("2024-04-01T20:30:00Z"), endDate: new Date("2024-04-02T00:30:00Z"), location: "Murphy's Irish Pub", @@ -151,16 +211,16 @@ async function main() { ]); console.log(`Database has been seeded with ${events.length} events 🌱`); - events.forEach(event => { + events.forEach((event) => { console.log(`Created event: ${event.title} (ID: ${event.id})`); }); } main() .catch((e) => { - console.error('Error seeding data:', e); + console.error("Error seeding data:", e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); - }); \ No newline at end of file + }); diff --git a/server/src/controllers/venue.controller.ts b/server/src/controllers/venue.controller.ts new file mode 100644 index 0000000..cf651c3 --- /dev/null +++ b/server/src/controllers/venue.controller.ts @@ -0,0 +1,206 @@ +import { Request, Response } from "express"; +import { VenueService } from "../services/venue.service"; +import { + VenueResponse, + VenueQuery, + VenueInput, + VenueUpdateInput, + VenueParams, +} from "../types/venue.types"; +import { ApiResponse } from "../types/api.types"; + +export class VenueController { + static async getById(req: Request, res: Response) { + try { + const id = Number(req.params.id); + const venue = await VenueService.findById(id); + + const response: ApiResponse = { + status: "success", + data: { + id: venue.id, + name: venue.name, + description: venue.description, + address: venue.address, + contact: venue.contact as VenueResponse["contact"], + images: venue.images, + createdAt: venue.createdAt.toISOString(), + updatedAt: venue.updatedAt.toISOString(), + }, + timestamp: new Date().toISOString(), + }; + + res.json(response); + } catch (error) { + const response: ApiResponse = { + status: "error", + message: error instanceof Error ? error.message : "Venue not found", + timestamp: new Date().toISOString(), + }; + + res.status(404).json(response); + } + } + + static async list(req: Request<{}, {}, {}, VenueQuery>, res: Response) { + try { + const result = await VenueService.findAll(req.query); + + const response: ApiResponse = { + status: "success", + data: result.venues.map(venue => ({ + id: venue.id, + name: venue.name, + description: venue.description, + address: venue.address, + contact: venue.contact as VenueResponse["contact"], + images: venue.images, + createdAt: venue.createdAt.toISOString(), + updatedAt: venue.updatedAt.toISOString(), + })), + pagination: result.pagination, + timestamp: new Date().toISOString(), + }; + + res.json(response); + } catch (error) { + const response: ApiResponse = { + status: "error", + message: error instanceof Error ? error.message : "Failed to fetch venues", + timestamp: new Date().toISOString(), + }; + + res.status(500).json(response); + } + } + + static async create(req: Request<{}, {}, VenueInput>, res: Response) { + try { + const venue = await VenueService.create(req.body); + + const response: ApiResponse = { + status: "success", + data: { + id: venue.id, + name: venue.name, + description: venue.description, + address: venue.address, + contact: venue.contact as VenueResponse["contact"], + images: venue.images, + createdAt: venue.createdAt.toISOString(), + updatedAt: venue.updatedAt.toISOString(), + }, + timestamp: new Date().toISOString(), + }; + + res.status(201).json(response); + } catch (error) { + const response: ApiResponse = { + status: "error", + message: error instanceof Error ? error.message : "Failed to create venue", + timestamp: new Date().toISOString(), + }; + + res.status(400).json(response); + } + } + + static async update( + req: Request, + res: Response + ) { + try { + const venue = await VenueService.update(req.params.id, req.body); + + const response: ApiResponse = { + status: "success", + data: { + id: venue.id, + name: venue.name, + description: venue.description, + address: venue.address, + contact: venue.contact as VenueResponse["contact"], + images: venue.images, + createdAt: venue.createdAt.toISOString(), + updatedAt: venue.updatedAt.toISOString(), + }, + timestamp: new Date().toISOString(), + }; + + res.json(response); + } catch (error) { + const response: ApiResponse = { + status: "error", + message: error instanceof Error ? error.message : "Failed to update venue", + timestamp: new Date().toISOString(), + }; + + const statusCode = error instanceof Error && error.message === "Venue not found" + ? 404 + : 400; + res.status(statusCode).json(response); + } + } + + static async replace( + req: Request, + res: Response + ) { + try { + const venue = await VenueService.replace(req.params.id, req.body); + + const response: ApiResponse = { + status: "success", + data: { + id: venue.id, + name: venue.name, + description: venue.description, + address: venue.address, + contact: venue.contact as VenueResponse["contact"], + images: venue.images, + createdAt: venue.createdAt.toISOString(), + updatedAt: venue.updatedAt.toISOString(), + }, + timestamp: new Date().toISOString(), + }; + + res.json(response); + } catch (error) { + const response: ApiResponse = { + status: "error", + message: error instanceof Error ? error.message : "Failed to replace venue", + timestamp: new Date().toISOString(), + }; + + const statusCode = error instanceof Error && error.message === "Venue not found" + ? 404 + : 400; + res.status(statusCode).json(response); + } + } + + static async delete(req: Request, res: Response) { + try { + await VenueService.delete(req.params.id); + + const response: ApiResponse = { + status: "success", + message: "Venue deleted successfully", + timestamp: new Date().toISOString(), + }; + + res.json(response); + } catch (error) { + const response: ApiResponse = { + status: "error", + message: error instanceof Error ? error.message : "Failed to delete venue", + timestamp: new Date().toISOString(), + }; + + const statusCode = error instanceof Error && error.message === "Venue not found" + ? 404 + : 400; + res.status(statusCode).json(response); + } + } +} \ No newline at end of file diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 818975a..95cd3fe 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import healthRouter from './health.routes'; import userRouter from './user.routes'; import eventRouter from './event.routes'; +import venueRouter from './venue.routes'; const router = Router(); @@ -9,5 +10,6 @@ const router = Router(); router.use('/health', healthRouter); router.use('/users', userRouter); router.use('/events', eventRouter); +router.use('/venues', venueRouter); export default router; \ No newline at end of file diff --git a/server/src/routes/venue.routes.ts b/server/src/routes/venue.routes.ts new file mode 100644 index 0000000..b712230 --- /dev/null +++ b/server/src/routes/venue.routes.ts @@ -0,0 +1,42 @@ +import { Router } from "express"; +import { VenueController } from "../controllers/venue.controller"; +import { validateRequest } from "../middleware/validateRequest"; +import { + VenueParamsSchema, + VenueQuerySchema, + VenueSchema, + VenueUpdateSchema +} from "../types/venue.types"; + +const router = Router(); + +// List venues (with query validation) +router.get("/", validateRequest.query(VenueQuerySchema), VenueController.list); + +// Get venue by ID +router.get("/:id", validateRequest.params(VenueParamsSchema), VenueController.getById); + +// Create venue +router.post("/", validateRequest.body(VenueSchema), VenueController.create); + +// Update venue (PATCH) +router.patch("/:id", + validateRequest.params(VenueParamsSchema), + validateRequest.body(VenueUpdateSchema), + VenueController.update +); + +// Replace venue (PUT) +router.put("/:id", + validateRequest.params(VenueParamsSchema), + validateRequest.body(VenueSchema), + VenueController.replace +); + +// Delete venue +router.delete("/:id", + validateRequest.params(VenueParamsSchema), + VenueController.delete +); + +export default router; \ No newline at end of file diff --git a/server/src/services/venue.service.ts b/server/src/services/venue.service.ts new file mode 100644 index 0000000..dd5a572 --- /dev/null +++ b/server/src/services/venue.service.ts @@ -0,0 +1,132 @@ +import prisma from "../config/database"; +import { VenueInput, VenueUpdateInput, VenueQuery } from "../types/venue.types"; +import { Prisma } from "@prisma/client"; + +export class VenueService { + static async findById(id: number) { + const venue = await prisma.venue.findUnique({ + where: { id } + }); + + if (!venue) { + throw new Error("Venue not found"); + } + + return venue; + } + + static async findAll(query: VenueQuery) { + const { page = 1, limit = 10, orderBy, order } = query; + const skip = (page - 1) * limit; + + const [venues, total] = await Promise.all([ + prisma.venue.findMany({ + skip, + take: limit, + orderBy: orderBy ? { [orderBy]: order || 'asc' } : undefined, + }), + prisma.venue.count() + ]); + + return { + venues, + pagination: { + currentPage: page, + totalPages: Math.ceil(total / limit), + totalItems: total, + itemsPerPage: limit, + hasNextPage: skip + venues.length < total, + hasPreviousPage: page > 1, + }, + }; + } + + static async create(data: VenueInput) { + try { + const venue = await prisma.venue.create({ + data: { + ...data, + contact: data.contact as Prisma.InputJsonValue + } + }); + + return venue; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new Error(`Failed to create venue: ${error.message}`); + } + throw error; + } + } + + static async update(id: string, data: VenueUpdateInput) { + try { + const venueId = Number(id); + if (isNaN(venueId)) throw new Error('Invalid venue ID'); + + const venue = await prisma.venue.update({ + where: { id: venueId }, + data: { + ...data, + contact: data.contact ? data.contact as Prisma.InputJsonValue : undefined + } + }); + + return venue; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2025") { + throw new Error("Venue not found"); + } + throw new Error(`Failed to update venue: ${error.message}`); + } + throw error; + } + } + + static async replace(id: string, data: VenueInput) { + try { + const venueId = Number(id); + if (isNaN(venueId)) throw new Error('Invalid venue ID'); + + const venue = await prisma.venue.update({ + where: { id: venueId }, + data: { + ...data, + contact: data.contact as Prisma.InputJsonValue + } + }); + + return venue; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2025") { + throw new Error("Venue not found"); + } + throw new Error(`Failed to replace venue: ${error.message}`); + } + throw error; + } + } + + static async delete(id: string) { + try { + const venueId = Number(id); + if (isNaN(venueId)) throw new Error('Invalid venue ID'); + + await prisma.venue.delete({ + where: { id: venueId } + }); + + return true; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2025") { + throw new Error("Venue not found"); + } + throw new Error(`Failed to delete venue: ${error.message}`); + } + throw error; + } + } +} \ No newline at end of file diff --git a/server/src/types/venue.types.ts b/server/src/types/venue.types.ts new file mode 100644 index 0000000..4d87626 --- /dev/null +++ b/server/src/types/venue.types.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +// Schema for contact information +const ContactSchema = z.object({ + phone: z.string().optional(), + email: z.string().email().optional(), + website: z.string().url().optional(), +}); + +// Schema for validating venue creation/updates +export const VenueSchema = z.object({ + name: z.string().min(1, "Name is required").max(100), + description: z.string().min(1, "Description is required").max(1000), + address: z.string().min(1, "Address is required").max(200), + contact: ContactSchema, + images: z.array(z.string().url()).min(1, "At least one image is required"), +}); + +// Schema for PATCH operations - all fields are optional +export const VenueUpdateSchema = VenueSchema.partial(); + +// Schema for query parameters +export const VenueQuerySchema = z.object({ + page: z.coerce.number().positive().optional(), + limit: z.coerce.number().min(1).max(100).optional(), + orderBy: z.enum(["name", "createdAt"]).optional(), + order: z.enum(["asc", "desc"]).optional(), +}); + +// Schema for URL parameters +export const VenueParamsSchema = z.object({ + id: z + .string() + .regex(/^\d+$/, "ID must be a positive integer") + .refine((val) => parseInt(val) > 0, "ID must be positive"), +}); + +// TypeScript types derived from Zod schemas +export type VenueInput = z.infer; +export type VenueUpdateInput = z.infer; +export type VenueQuery = z.infer; +export type VenueParams = z.infer; + +// Type for API responses +export type VenueResponse = { + id: number; + name: string; + description: string; + address: string; + contact: { + phone?: string; + email?: string; + website?: string; + }; + images: string[]; + createdAt: string; + updatedAt: string; +}; diff --git a/server/tsconfig.json b/server/tsconfig.json index a19d873..45af23a 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,111 +1,16 @@ { "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "es2020" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - - /* Modules */ - "module": "commonjs" /* Specify what module code is generated. */, - "rootDir": "./src" /* Specify the root folder within your source files. */, - // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - "types": ["node"], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ - // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ - // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ - // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ - // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ - // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - - /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - "outDir": "./dist" /* Specify an output folder for all emitted files. */, - // "removeComments": true, /* Disable emitting comments. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ - // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, - - /* Type Checking */ - "strict": true /* Enable all strict type-checking options. */, - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "target": "es2020", + "module": "commonjs", + "rootDir": ".", + "outDir": "./dist", + "baseUrl": ".", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] }, - "exclude": ["prisma", "node_modules"] + "include": ["src/**/*", "prisma/**/*"], + "exclude": ["node_modules"] } From 829f720fbbd8913161edf000e606fa5c0f97fec7 Mon Sep 17 00:00:00 2001 From: Morais-C Date: Fri, 15 Nov 2024 11:49:55 +0000 Subject: [PATCH 2/4] feat(venues): add geospatial capabilities and GeoJSON support - Add coordinates field to Venue model - Store as JSON with lat/lng structure - Add validation for coordinate ranges - Update seed data with real venue coordinates - Implement GeoJSON support - Add GeoJSON types and interfaces - Create new /venues/:id/geojson endpoint - Convert lat/lng to GeoJSON [longitude, latitude] format - Include venue properties in GeoJSON response - Update documentation - Add coordinate validation rules - Document GeoJSON endpoint - Add example responses in both formats - Update Postman collection with new endpoint - Technical decisions - Store coordinates as JSON for Render compatibility - Document future migration path to PostGIS - Implement proper coordinate validation - Follow GeoJSON specification Part of Event Zone Search feature - Step 2/4 --- .../2024.11.15_Approach_to_EventZoneSearch.md | 160 ++++++++++++++---- postman/README.md | 80 +++++++++ .../v52-tier3-team-34.postman_collection.json | 62 ++++++- server/prisma/schema.prisma | 1 + server/prisma/seed.ts | 12 ++ server/src/controllers/venue.controller.ts | 49 ++++++ server/src/routes/venue.routes.ts | 6 + server/src/services/venue.service.ts | 9 +- server/src/types/venue.types.ts | 42 +++++ 9 files changed, 384 insertions(+), 37 deletions(-) diff --git a/docs/2024.11.15_Approach_to_EventZoneSearch.md b/docs/2024.11.15_Approach_to_EventZoneSearch.md index cd5d3ef..9046669 100644 --- a/docs/2024.11.15_Approach_to_EventZoneSearch.md +++ b/docs/2024.11.15_Approach_to_EventZoneSearch.md @@ -5,49 +5,63 @@ Implementation of geospatial search functionality for live music events, includi ## Implementation Steps -### Step 1: Add Venue Model & Basic CRUD +### Step 1: Add Venue Model & Basic CRUD ✅ 1. **Code Changes** - Update Prisma schema with Venue model - - Create venue types/validation schemas - - Implement Venue controller/service/routes - - Follow existing CRUD pattern + - Basic fields: name, description, address + - JSON field for contact info (phone, email, website) + - String array for images + - Timestamps for auditing + - Create venue types/validation schemas using Zod + - Implement service with Prisma.InputJsonValue for JSON handling + - Follow existing controller/routes patterns for consistency 2. **Testing** - Add venue endpoints to Postman collection - - Create example requests/responses - - Test all CRUD operations + - Create realistic example data + - Test validation rules + - Verify JSON field handling + - Check pagination and sorting 3. **Documentation** - - Document new venue endpoints - - Update API documentation - - Add request/response examples - -4. **Commit** - - Message: "feat: add basic venue management" - - List schema changes - - List new endpoints - -### Step 2: Enhance Venue with Geospatial + - Document API endpoints in Postman + - Include example requests/responses + - Document validation rules + - Add error scenarios + +4. **Learnings & Notes** + - JSON field requires special typing (Prisma.InputJsonValue) + - Keep consistent patterns with existing code + - Maintain proper TypeScript types throughout + - Consider validation rules carefully + +### Step 2: Enhance Venue with Geospatial ✅ 1. **Code Changes** - - Add coordinates to Venue model - - Update validation schemas - - Add GeoJSON response types - - Implement location search + - Added coordinates as JSON field to Venue model + - Created CoordinatesSchema with validation: + - Latitude: -90 to 90 + - Longitude: -180 to 180 + - Added GeoJSON types and response formatting + - Implemented dedicated GeoJSON endpoint 2. **Testing** - - Add geospatial queries to Postman - - Test coordinate validation - - Test GeoJSON responses + - Added geospatial queries to Postman + - Tested coordinate validation + - Verified GeoJSON format compliance + - Tested with real-world coordinates 3. **Documentation** - - Document geospatial features - - Add GeoJSON examples - - Update schema documentation - -4. **Commit** - - Message: "feat: add geospatial capabilities to venues" - - Detail coordinate system - - List new queries + - Added GeoJSON endpoint documentation + - Included coordinate validation rules + - Documented both response formats: + - Standard (lat/lng object) + - GeoJSON (coordinates array) + +4. **Learnings & Notes** + - GeoJSON uses [longitude, latitude] order (different from typical lat/lng) + - JSON fields in Prisma require InputJsonValue type for writes + - Coordinate validation is crucial for map reliability + - Separate endpoint for GeoJSON keeps responses clean ### Step 3: Connect Events & Venues 1. **Code Changes** @@ -134,3 +148,87 @@ Implementation of geospatial search functionality for live music events, includi - New dependencies - Configuration updates - Backward compatibility + +## Best Practices (Updated from Step 1) +1. **Code Organization** + - Keep types and validation together + - Use service layer for business logic + - Handle JSON fields properly + - Maintain consistent error handling + +2. **Testing Strategy** + - Test all CRUD operations + - Include validation edge cases + - Test pagination and sorting + - Verify JSON field handling + +3. **Documentation Approach** + - Document as you code + - Include real-world examples + - Show error scenarios + - Keep Postman in sync + +## Dependencies & Tools +- Prisma with PostgreSQL +- Zod for validation +- Express.js +- TypeScript +- Postman for API testing + +## Migration Strategy +1. Create new model +2. Run migration +3. Seed test data +4. Verify existing functionality + +## Notes for Future Steps +- Consider JSON field performance +- Plan for data relationships +- Think about validation complexity +- Keep backward compatibility + +## Technical Decisions + +### Geospatial Implementation (Step 2) +1. **Decision**: Implement geospatial features without PostGIS + - Store coordinates as JSON in PostgreSQL + - Implement distance calculations in JavaScript + - Return GeoJSON format responses + +2. **Rationale**: + - Render free tier doesn't support PostGIS extension + - Simple implementation sufficient for MVP needs + - Easier deployment and maintenance + - No additional database dependencies + +3. **Trade-offs**: + - ✅ Pros: + - Simpler deployment + - No additional dependencies + - Works with free tier hosting + - Easier to understand and maintain + + - ❌ Cons: + - Less performant for large datasets + - Limited geospatial query capabilities + - Distance calculations less precise + - No spatial indexing + +4. **Future Considerations**: + - Can migrate to PostGIS later if needed + - Structure code to make future migration easier + - Monitor performance as data grows + - Consider upgrade path if advanced features needed + +5. **Implementation Details**: + - Coordinates stored as `{ lat: number, lng: number }` + - GeoJSON endpoint converts to `[longitude, latitude]` + - Strong validation prevents invalid coordinates + - TypeScript types ensure format consistency + +6. **Key Learnings**: + - Keep coordinate formats consistent + - Validate coordinates at schema level + - Consider map integration needs early + - Document coordinate order clearly + - Plan for future performance needs diff --git a/postman/README.md b/postman/README.md index a3887c8..c5a490b 100644 --- a/postman/README.md +++ b/postman/README.md @@ -515,6 +515,10 @@ Current variables: "email": "contact@venue.com", "website": "https://www.venue.com" }, + "coordinates": { + "lat": 51.509865, + "lng": -0.118092 + }, "images": ["https://example.com/venue1.jpg"] } ``` @@ -544,6 +548,82 @@ Current variables: } ``` +#### Response Format +Venues can be returned in two formats: + +1. **Standard Format**: +```json +{ + "id": 1, + "name": "Blue Note Jazz Club", + "coordinates": { + "lat": 40.730483, + "lng": -74.000339 + } + // ... other fields +} +``` + +2. **GeoJSON Format** (for map integration): +```json +{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-74.000339, 40.730483] // [longitude, latitude] + }, + "properties": { + "id": 1, + "name": "Blue Note Jazz Club" + // ... other venue properties + } +} +``` + +#### Get Venue GeoJSON +- **Endpoint**: GET `/api/venues/:id/geojson` +- **Parameters**: + - id: number (positive integer) +- **Success Response** (200): + ```json + { + "status": "success", + "data": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-74.000339, 40.730483] // [longitude, latitude] + }, + "properties": { + "id": 1, + "name": "Blue Note Jazz Club", + "description": "Historic jazz venue featuring nightly live performances...", + "address": "131 W 3rd St, New York, NY 10012", + "contact": { + "phone": "+1-212-475-8592", + "email": "info@bluenote.net", + "website": "https://www.bluenotejazz.com" + }, + "images": [ + "https://example.com/venues/bluenote1.jpg", + "https://example.com/venues/bluenote2.jpg" + ], + "createdAt": "2024-03-20T10:00:00.000Z", + "updatedAt": "2024-03-20T10:00:00.000Z" + } + }, + "timestamp": "2024-03-20T10:00:00.000Z" + } + ``` +- **Error Response** (404): + ```json + { + "status": "error", + "message": "Venue not found", + "timestamp": "2024-03-20T10:00:00.000Z" + } + ``` + ## Testing Instructions ### Basic Request Testing diff --git a/postman/v52-tier3-team-34.postman_collection.json b/postman/v52-tier3-team-34.postman_collection.json index 514e269..566dfcb 100644 --- a/postman/v52-tier3-team-34.postman_collection.json +++ b/postman/v52-tier3-team-34.postman_collection.json @@ -624,7 +624,11 @@ "https://example.com/venues/bluenote2.jpg" ], "createdAt": "2024-03-20T10:00:00.000Z", - "updatedAt": "2024-03-20T10:00:00.000Z" + "updatedAt": "2024-03-20T10:00:00.000Z", + "coordinates": { + "lat": 40.730483, + "lng": -74.000339 + } } ], "pagination": { @@ -680,7 +684,11 @@ "https://example.com/venues/bluenote2.jpg" ], "createdAt": "2024-03-20T10:00:00.000Z", - "updatedAt": "2024-03-20T10:00:00.000Z" + "updatedAt": "2024-03-20T10:00:00.000Z", + "coordinates": { + "lat": 40.730483, + "lng": -74.000339 + } }, "timestamp": "2024-03-20T10:00:00.000Z" } @@ -699,7 +707,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"name\": \"New Test Venue\",\n \"description\": \"A great new venue for live music\",\n \"address\": \"123 Test Street, Test City\",\n \"contact\": {\n \"phone\": \"+1-555-0123\",\n \"email\": \"contact@testvenue.com\",\n \"website\": \"https://www.testvenue.com\"\n },\n \"images\": [\n \"https://example.com/venues/test1.jpg\"\n ]\n}" + "raw": "{\n \"name\": \"New Test Venue\",\n \"description\": \"A great new venue for live music\",\n \"address\": \"123 Test Street, Test City\",\n \"contact\": {\n \"phone\": \"+1-555-0123\",\n \"email\": \"contact@testvenue.com\",\n \"website\": \"https://www.testvenue.com\"\n },\n \"coordinates\": {\n \"lat\": 51.509865,\n \"lng\": -0.118092\n },\n \"images\": [\n \"https://example.com/venues/test1.jpg\"\n ]\n}" }, "url": { "raw": "{{baseUrl}}/api/venues", @@ -765,6 +773,54 @@ }, "description": "Delete a venue by ID" } + }, + { + "name": "Get Venue GeoJSON", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/venues/1/geojson", + "host": ["{{baseUrl}}"], + "path": ["api", "venues", "1", "geojson"] + }, + "description": "Get venue details in GeoJSON format for map integration" + }, + "response": [ + { + "name": "Success Response", + "status": "OK", + "code": 200, + "body": { + "status": "success", + "data": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-74.000339, 40.730483] + }, + "properties": { + "id": 1, + "name": "Blue Note Jazz Club", + "description": "Historic jazz venue featuring nightly live performances...", + "address": "131 W 3rd St, New York, NY 10012", + "contact": { + "phone": "+1-212-475-8592", + "email": "info@bluenote.net", + "website": "https://www.bluenotejazz.com" + }, + "images": [ + "https://example.com/venues/bluenote1.jpg", + "https://example.com/venues/bluenote2.jpg" + ], + "createdAt": "2024-03-20T10:00:00.000Z", + "updatedAt": "2024-03-20T10:00:00.000Z" + } + }, + "timestamp": "2024-03-20T10:00:00.000Z" + } + } + ] } ] } diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b2c516a..470dc20 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -39,6 +39,7 @@ model Venue { address String contact Json // {phone, email, website} images String[] // Array of image URLs + coordinates Json // {lat: number, lng: number} createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index 8cffccf..e438ac6 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -20,6 +20,10 @@ async function main() { email: "info@bluenote.net", website: "https://www.bluenotejazz.com" }, + coordinates: { + lat: 40.730483, + lng: -74.000339 + }, images: [ "https://example.com/venues/bluenote1.jpg", "https://example.com/venues/bluenote2.jpg" @@ -36,6 +40,10 @@ async function main() { email: "bookings@basementbar.co.uk", website: "https://www.thebasementbar.co.uk" }, + coordinates: { + lat: 51.527817, + lng: -0.082448 + }, images: [ "https://example.com/venues/basement1.jpg" ] @@ -51,6 +59,10 @@ async function main() { email: "hola@flamencacasa.es", website: "https://www.flamencacasa.es" }, + coordinates: { + lat: 40.423697, + lng: -3.710432 + }, images: [ "https://example.com/venues/flamenco1.jpg", "https://example.com/venues/flamenco2.jpg", diff --git a/server/src/controllers/venue.controller.ts b/server/src/controllers/venue.controller.ts index cf651c3..a157d12 100644 --- a/server/src/controllers/venue.controller.ts +++ b/server/src/controllers/venue.controller.ts @@ -6,6 +6,8 @@ import { VenueInput, VenueUpdateInput, VenueParams, + GeoJSONFeature, + VenueProperties } from "../types/venue.types"; import { ApiResponse } from "../types/api.types"; @@ -23,6 +25,7 @@ export class VenueController { description: venue.description, address: venue.address, contact: venue.contact as VenueResponse["contact"], + coordinates: venue.coordinates as VenueResponse["coordinates"], images: venue.images, createdAt: venue.createdAt.toISOString(), updatedAt: venue.updatedAt.toISOString(), @@ -54,6 +57,7 @@ export class VenueController { description: venue.description, address: venue.address, contact: venue.contact as VenueResponse["contact"], + coordinates: venue.coordinates as VenueResponse["coordinates"], images: venue.images, createdAt: venue.createdAt.toISOString(), updatedAt: venue.updatedAt.toISOString(), @@ -86,6 +90,7 @@ export class VenueController { description: venue.description, address: venue.address, contact: venue.contact as VenueResponse["contact"], + coordinates: venue.coordinates as VenueResponse["coordinates"], images: venue.images, createdAt: venue.createdAt.toISOString(), updatedAt: venue.updatedAt.toISOString(), @@ -120,6 +125,7 @@ export class VenueController { description: venue.description, address: venue.address, contact: venue.contact as VenueResponse["contact"], + coordinates: venue.coordinates as VenueResponse["coordinates"], images: venue.images, createdAt: venue.createdAt.toISOString(), updatedAt: venue.updatedAt.toISOString(), @@ -157,6 +163,7 @@ export class VenueController { description: venue.description, address: venue.address, contact: venue.contact as VenueResponse["contact"], + coordinates: venue.coordinates as VenueResponse["coordinates"], images: venue.images, createdAt: venue.createdAt.toISOString(), updatedAt: venue.updatedAt.toISOString(), @@ -203,4 +210,46 @@ export class VenueController { res.status(statusCode).json(response); } } + + static async getGeoJson(req: Request, res: Response) { + try { + const id = Number(req.params.id); + const venue = await VenueService.findById(id); + + const geoJsonResponse: ApiResponse = { + status: "success", + data: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [ + (venue.coordinates as { lng: number; lat: number }).lng, + (venue.coordinates as { lng: number; lat: number }).lat + ] + }, + properties: { + id: venue.id, + name: venue.name, + description: venue.description, + address: venue.address, + contact: venue.contact as VenueProperties["contact"], + images: venue.images, + createdAt: venue.createdAt.toISOString(), + updatedAt: venue.updatedAt.toISOString() + } + }, + timestamp: new Date().toISOString() + }; + + res.json(geoJsonResponse); + } catch (error) { + const response: ApiResponse = { + status: "error", + message: error instanceof Error ? error.message : "Venue not found", + timestamp: new Date().toISOString() + }; + + res.status(404).json(response); + } + } } \ No newline at end of file diff --git a/server/src/routes/venue.routes.ts b/server/src/routes/venue.routes.ts index b712230..865801b 100644 --- a/server/src/routes/venue.routes.ts +++ b/server/src/routes/venue.routes.ts @@ -39,4 +39,10 @@ router.delete("/:id", VenueController.delete ); +// Get venue in GeoJSON format +router.get("/:id/geojson", + validateRequest.params(VenueParamsSchema), + VenueController.getGeoJson +); + export default router; \ No newline at end of file diff --git a/server/src/services/venue.service.ts b/server/src/services/venue.service.ts index dd5a572..5537774 100644 --- a/server/src/services/venue.service.ts +++ b/server/src/services/venue.service.ts @@ -46,7 +46,8 @@ export class VenueService { const venue = await prisma.venue.create({ data: { ...data, - contact: data.contact as Prisma.InputJsonValue + contact: data.contact as Prisma.InputJsonValue, + coordinates: data.coordinates as Prisma.InputJsonValue } }); @@ -68,7 +69,8 @@ export class VenueService { where: { id: venueId }, data: { ...data, - contact: data.contact ? data.contact as Prisma.InputJsonValue : undefined + contact: data.contact ? data.contact as Prisma.InputJsonValue : undefined, + coordinates: data.coordinates ? data.coordinates as Prisma.InputJsonValue : undefined } }); @@ -93,7 +95,8 @@ export class VenueService { where: { id: venueId }, data: { ...data, - contact: data.contact as Prisma.InputJsonValue + contact: data.contact as Prisma.InputJsonValue, + coordinates: data.coordinates as Prisma.InputJsonValue } }); diff --git a/server/src/types/venue.types.ts b/server/src/types/venue.types.ts index 4d87626..91d39fa 100644 --- a/server/src/types/venue.types.ts +++ b/server/src/types/venue.types.ts @@ -1,5 +1,15 @@ import { z } from "zod"; +// Schema for coordinates +const CoordinatesSchema = z.object({ + lat: z.number() + .min(-90, "Latitude must be between -90 and 90") + .max(90, "Latitude must be between -90 and 90"), + lng: z.number() + .min(-180, "Longitude must be between -180 and 180") + .max(180, "Longitude must be between -180 and 180") +}); + // Schema for contact information const ContactSchema = z.object({ phone: z.string().optional(), @@ -14,6 +24,7 @@ export const VenueSchema = z.object({ address: z.string().min(1, "Address is required").max(200), contact: ContactSchema, images: z.array(z.string().url()).min(1, "At least one image is required"), + coordinates: CoordinatesSchema }); // Schema for PATCH operations - all fields are optional @@ -41,6 +52,33 @@ export type VenueUpdateInput = z.infer; export type VenueQuery = z.infer; export type VenueParams = z.infer; +// GeoJSON types +export type GeoJSONPoint = { + type: 'Point'; + coordinates: [number, number]; // [longitude, latitude] +}; + +export type GeoJSONFeature = { + type: 'Feature'; + geometry: GeoJSONPoint; + properties: VenueProperties; +}; + +export type VenueProperties = { + id: number; + name: string; + description: string; + address: string; + contact: { + phone?: string; + email?: string; + website?: string; + }; + images: string[]; + createdAt: string; + updatedAt: string; +}; + // Type for API responses export type VenueResponse = { id: number; @@ -52,6 +90,10 @@ export type VenueResponse = { email?: string; website?: string; }; + coordinates: { + lat: number; + lng: number; + }; images: string[]; createdAt: string; updatedAt: string; From 5c617c87538c8afb0e5af9bb3260dc53a12680c6 Mon Sep 17 00:00:00 2001 From: Morais-C Date: Fri, 15 Nov 2024 12:45:15 +0000 Subject: [PATCH 3/4] feat(events): link events with venues and add GeoJSON support - Update Event model with venue relationship - Add venueId field (required) - Create foreign key constraint - Remove redundant location field - Add venue relation field - Enhance Event responses - Include venue details in responses - Add GeoJSON endpoint for events - Handle missing venue cases - Maintain consistent coordinate format - Update validation and types - Add venueId validation - Create EventGeoJSONFeature type - Update EventResponse type with venue - Add proper type casting - Update documentation - Add GeoJSON endpoint examples - Document venue relationship - Update Postman collection - Add error scenarios Technical notes: - Use conditional chaining for optional venue data - Follow GeoJSON specification - Maintain consistent coordinate order (lng/lat) - Handle venue not found errors Part of Event Zone Search feature - Step 3/4 --- .../2024.11.15_Approach_to_EventZoneSearch.md | 53 ++++-- postman/README.md | 156 +++++++++++++----- .../v52-tier3-team-34.postman_collection.json | 96 ++++++++++- .../migration.sql | 17 ++ server/prisma/schema.prisma | 4 +- server/prisma/seed.ts | 38 ++--- server/src/controllers/event.controller.ts | 103 ++++++++++-- server/src/routes/event.routes.ts | 6 + server/src/services/event.service.ts | 30 +++- server/src/types/event.types.ts | 39 ++++- 10 files changed, 434 insertions(+), 108 deletions(-) create mode 100644 server/prisma/migrations/20241115115952_add_venue_relation_to_events/migration.sql diff --git a/docs/2024.11.15_Approach_to_EventZoneSearch.md b/docs/2024.11.15_Approach_to_EventZoneSearch.md index 9046669..924b113 100644 --- a/docs/2024.11.15_Approach_to_EventZoneSearch.md +++ b/docs/2024.11.15_Approach_to_EventZoneSearch.md @@ -63,27 +63,50 @@ Implementation of geospatial search functionality for live music events, includi - Coordinate validation is crucial for map reliability - Separate endpoint for GeoJSON keeps responses clean -### Step 3: Connect Events & Venues +### Step 3: Connect Events & Venues ✅ 1. **Code Changes** - - Update Event model with venue relation - - Enhance Event controller - - Update event creation flow - - Modify event responses + - Updated Event model with venue relation + - Added venueId field + - Created foreign key constraint + - Removed redundant location field + - Enhanced Event responses with venue data + - Include venue details in responses + - Added GeoJSON endpoint for events + - Handle missing venue cases + - Updated validation schemas + - Added venueId validation + - Made venue required for events 2. **Testing** - - Update event endpoints in Postman - - Test venue relationships - - Verify data integrity + - Updated event endpoints in Postman + - Tested venue relationships + - Verified data integrity + - Tested GeoJSON responses + - Validated error cases 3. **Documentation** - - Update event endpoint docs - - Document relationship rules - - Add new response formats + - Updated event endpoint docs + - Added GeoJSON format examples + - Documented relationship rules + - Added error scenarios -4. **Commit** - - Message: "feat: link events with venues" - - List relationship changes - - Document foreign keys +4. **Learnings & Notes** + - Conditional chaining for optional venue data + - Type casting for JSON fields + - Consistent GeoJSON format between venues/events + - Error handling for missing venues + +5. **Technical Decisions** + - Include venue details in event responses + - Add dedicated GeoJSON endpoints + - Use consistent coordinate format + - Handle venue relationship errors + +6. **Key Learnings** + - Maintain data consistency + - Handle optional relationships + - Follow GeoJSON standards + - Consider error scenarios ### Step 4: Implement Zone Search 1. **Code Changes** diff --git a/postman/README.md b/postman/README.md index c5a490b..9f342af 100644 --- a/postman/README.md +++ b/postman/README.md @@ -17,11 +17,13 @@ postman/ The API can be tested in two environments: ### 1. Local Environment + - File: `local.postman_environment.json` - Base URL: `http://localhost:3000` - Use for local development and testing ### 2. Production Environment + - File: `production.postman_environment.json` - Base URL: `https://v52-tier3-team-34.onrender.com` - Use for testing the deployed API @@ -35,6 +37,7 @@ The API can be tested in two environments: ### Environment Variables Current variables: + - `baseUrl`: Base URL for all API requests - Local: `http://localhost:3000` - Production: `https://v52-tier3-team-34.onrender.com` @@ -88,8 +91,9 @@ Current variables: ### Users #### List Users + - **Endpoint**: GET `/api/users` -- **Query Parameters**: +- **Query Parameters**: - page: number (optional, default: 1) - limit: number (optional, default: 10, max: 100) - orderBy: string (optional, values: 'name', 'email', 'createdAt') @@ -120,8 +124,9 @@ Current variables: ``` #### Get User by ID + - **Endpoint**: GET `/api/users/:id` -- **Parameters**: +- **Parameters**: - id: number (positive integer) - **Success Response** (200): ```json @@ -147,14 +152,15 @@ Current variables: ``` #### Create User + - **Endpoint**: POST `/api/users` -- **Request Body**: +- **Request Body**: ```json { "email": "new.user@example.com", "name": "New User", "googleId": "google_new_123", - "profileImage": "https://example.com/avatars/new.jpg" // optional + "profileImage": "https://example.com/avatars/new.jpg" // optional } ``` - **Success Response** (201): @@ -181,10 +187,11 @@ Current variables: ``` #### Update User + - **Endpoint**: PATCH `/api/users/:id` -- **Parameters**: +- **Parameters**: - id: number (positive integer) -- **Request Body** (all fields optional): +- **Request Body** (all fields optional): ```json { "email": "updated.email@example.com", @@ -217,16 +224,17 @@ Current variables: ``` #### Replace User + - **Endpoint**: PUT `/api/users/:id` -- **Parameters**: +- **Parameters**: - id: number (positive integer) -- **Request Body** (all fields required): +- **Request Body** (all fields required): ```json { "email": "replaced.user@example.com", "name": "Replaced User", "googleId": "google_replaced_123", - "profileImage": "https://example.com/avatars/replaced.jpg" // optional + "profileImage": "https://example.com/avatars/replaced.jpg" // optional } ``` - **Success Response** (200): @@ -253,8 +261,9 @@ Current variables: ``` #### Delete User + - **Endpoint**: DELETE `/api/users/:id` -- **Parameters**: +- **Parameters**: - id: number (positive integer) - **Success Response** (200): ```json @@ -276,8 +285,9 @@ Current variables: ### Events #### List Events + - **Endpoint**: GET `/api/events` -- **Query Parameters**: +- **Query Parameters**: - page: number (optional, default: 1) - limit: number (optional, default: 10, max: 100) - status: string (optional, values: 'draft', 'published', 'cancelled') @@ -313,8 +323,9 @@ Current variables: ``` #### Get Event by ID + - **Endpoint**: GET `/api/events/:id` -- **Parameters**: +- **Parameters**: - id: number (positive integer) - **Success Response** (200): ```json @@ -336,16 +347,17 @@ Current variables: ``` #### Create Event + - **Endpoint**: POST `/api/events` -- **Request Body**: +- **Request Body**: ```json { - "title": "New Event", - "description": "Event description", - "startDate": "2024-04-01T10:00:00Z", - "endDate": "2024-04-01T12:00:00Z", - "location": "Event Location", - "status": "draft" + "title": "New Jazz Night", + "description": "Live jazz performance", + "startDate": "2024-04-01T19:00:00Z", + "endDate": "2024-04-01T23:00:00Z", + "status": "published", + "venueId": 1 } ``` - **Success Response** (201): @@ -353,25 +365,35 @@ Current variables: { "status": "success", "data": { - "id": 5, - "title": "New Event", - "description": "Event description", - "startDate": "2024-04-01T10:00:00.000Z", - "endDate": "2024-04-01T12:00:00.000Z", - "location": "Event Location", - "status": "draft", - "createdAt": "2024-03-12T10:00:00.000Z", - "updatedAt": "2024-03-12T10:00:00.000Z" + "id": 1, + "title": "New Jazz Night", + "description": "Live jazz performance", + "startDate": "2024-04-01T19:00:00.000Z", + "endDate": "2024-04-01T23:00:00.000Z", + "status": "published", + "venueId": 1, + "venue": { + "id": 1, + "name": "Blue Note Jazz Club", + "address": "131 W 3rd St, New York, NY 10012", + "coordinates": { + "lat": 40.730483, + "lng": -74.000339 + } + }, + "createdAt": "2024-03-20T10:00:00.000Z", + "updatedAt": "2024-03-20T10:00:00.000Z" }, - "timestamp": "2024-03-12T10:00:00.000Z" + "timestamp": "2024-03-20T10:00:00.000Z" } ``` #### Update Event + - **Endpoint**: PATCH `/api/events/:id` -- **Parameters**: +- **Parameters**: - id: number (positive integer) -- **Request Body** (all fields optional): +- **Request Body** (all fields optional): ```json { "title": "Updated Event Title", @@ -398,10 +420,11 @@ Current variables: ``` #### Replace Event + - **Endpoint**: PUT `/api/events/:id` -- **Parameters**: +- **Parameters**: - id: number (positive integer) -- **Request Body** (all fields required): +- **Request Body** (all fields required): ```json { "title": "Replaced Event", @@ -432,8 +455,9 @@ Current variables: ``` #### Delete Event + - **Endpoint**: DELETE `/api/events/:id` -- **Parameters**: +- **Parameters**: - id: number (positive integer) - **Success Response** (200): ```json @@ -444,11 +468,54 @@ Current variables: } ``` +#### Get Event GeoJSON +- **Endpoint**: GET `/api/events/:id/geojson` +- **Parameters**: + - id: number (positive integer) +- **Success Response** (200): + ```json + { + "status": "success", + "data": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-74.000339, 40.730483] // [longitude, latitude] + }, + "properties": { + "id": 1, + "title": "Jazz Night at Blue Note", + "description": "Live jazz quartet performing classic standards...", + "startDate": "2024-03-25T19:00:00.000Z", + "endDate": "2024-03-25T23:00:00.000Z", + "status": "published", + "venue": { + "id": 1, + "name": "Blue Note Jazz Club", + "address": "131 W 3rd St, New York, NY 10012" + }, + "createdAt": "2024-03-20T10:00:00.000Z", + "updatedAt": "2024-03-20T10:00:00.000Z" + } + }, + "timestamp": "2024-03-20T10:00:00.000Z" + } + ``` +- **Error Response** (404): + ```json + { + "status": "error", + "message": "Event not found", + "timestamp": "2024-03-20T10:00:00.000Z" + } + ``` + ### Venues #### List Venues + - **Endpoint**: GET `/api/venues` -- **Query Parameters**: +- **Query Parameters**: - page: number (optional, default: 1) - limit: number (optional, default: 10, max: 100) - orderBy: string (optional, values: 'name', 'createdAt') @@ -489,8 +556,9 @@ Current variables: ``` #### Get Venue by ID + - **Endpoint**: GET `/api/venues/:id` -- **Parameters**: +- **Parameters**: - id: number (positive integer) - **Success Response** (200): Same structure as single venue in list response - **Error Response** (404): @@ -503,8 +571,9 @@ Current variables: ``` #### Create Venue + - **Endpoint**: POST `/api/venues` -- **Request Body**: +- **Request Body**: ```json { "name": "New Venue", @@ -525,18 +594,21 @@ Current variables: - **Success Response** (201): Same structure as Get Venue response #### Update Venue + - **Endpoint**: PATCH `/api/venues/:id` - **Parameters**: id (number) - **Request Body** (all fields optional): Same structure as Create - **Success Response** (200): Same structure as Get Venue response #### Replace Venue + - **Endpoint**: PUT `/api/venues/:id` - **Parameters**: id (number) - **Request Body** (all fields required): Same structure as Create - **Success Response** (200): Same structure as Get Venue response #### Delete Venue + - **Endpoint**: DELETE `/api/venues/:id` - **Parameters**: id (number) - **Success Response** (200): @@ -549,9 +621,11 @@ Current variables: ``` #### Response Format + Venues can be returned in two formats: 1. **Standard Format**: + ```json { "id": 1, @@ -565,12 +639,13 @@ Venues can be returned in two formats: ``` 2. **GeoJSON Format** (for map integration): + ```json { "type": "Feature", "geometry": { "type": "Point", - "coordinates": [-74.000339, 40.730483] // [longitude, latitude] + "coordinates": [-74.000339, 40.730483] // [longitude, latitude] }, "properties": { "id": 1, @@ -581,8 +656,9 @@ Venues can be returned in two formats: ``` #### Get Venue GeoJSON + - **Endpoint**: GET `/api/venues/:id/geojson` -- **Parameters**: +- **Parameters**: - id: number (positive integer) - **Success Response** (200): ```json @@ -592,7 +668,7 @@ Venues can be returned in two formats: "type": "Feature", "geometry": { "type": "Point", - "coordinates": [-74.000339, 40.730483] // [longitude, latitude] + "coordinates": [-74.000339, 40.730483] // [longitude, latitude] }, "properties": { "id": 1, diff --git a/postman/v52-tier3-team-34.postman_collection.json b/postman/v52-tier3-team-34.postman_collection.json index 566dfcb..dc436be 100644 --- a/postman/v52-tier3-team-34.postman_collection.json +++ b/postman/v52-tier3-team-34.postman_collection.json @@ -478,15 +478,47 @@ ], "body": { "mode": "raw", - "raw": "{\n \"title\": \"New Test Event\",\n \"description\": \"This is a test event\",\n \"startDate\": \"2024-04-01T10:00:00Z\",\n \"endDate\": \"2024-04-01T12:00:00Z\",\n \"location\": \"Test Location\",\n \"status\": \"draft\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/events", - "host": ["{{baseUrl}}"], - "path": ["api", "events"] - }, - "description": "Create a new event" - } + "raw": { + "title": "New Jazz Night", + "description": "Live jazz performance", + "startDate": "2024-04-01T19:00:00Z", + "endDate": "2024-04-01T23:00:00Z", + "status": "published", + "venueId": 1 + } + } + }, + "response": [ + { + "name": "Success Response", + "status": "Created", + "code": 201, + "body": { + "status": "success", + "data": { + "id": 1, + "title": "New Jazz Night", + "description": "Live jazz performance", + "startDate": "2024-04-01T19:00:00.000Z", + "endDate": "2024-04-01T23:00:00.000Z", + "status": "published", + "venueId": 1, + "venue": { + "id": 1, + "name": "Blue Note Jazz Club", + "address": "131 W 3rd St, New York, NY 10012", + "coordinates": { + "lat": 40.730483, + "lng": -74.000339 + } + }, + "createdAt": "2024-03-20T10:00:00.000Z", + "updatedAt": "2024-03-20T10:00:00.000Z" + }, + "timestamp": "2024-03-20T10:00:00.000Z" + } + } + ] }, { "name": "Update Event", @@ -544,6 +576,52 @@ }, "description": "Delete an event by ID" } + }, + { + "name": "Get Event GeoJSON", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/events/1/geojson", + "host": ["{{baseUrl}}"], + "path": ["api", "events", "1", "geojson"] + }, + "description": "Get event details in GeoJSON format for map integration" + }, + "response": [ + { + "name": "Success Response", + "status": "OK", + "code": 200, + "body": { + "status": "success", + "data": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-74.000339, 40.730483] + }, + "properties": { + "id": 1, + "title": "Jazz Night at Blue Note", + "description": "Live jazz quartet performing classic standards...", + "startDate": "2024-03-25T19:00:00.000Z", + "endDate": "2024-03-25T23:00:00.000Z", + "status": "published", + "venue": { + "id": 1, + "name": "Blue Note Jazz Club", + "address": "131 W 3rd St, New York, NY 10012" + }, + "createdAt": "2024-03-20T10:00:00.000Z", + "updatedAt": "2024-03-20T10:00:00.000Z" + } + }, + "timestamp": "2024-03-20T10:00:00.000Z" + } + } + ] } ] }, diff --git a/server/prisma/migrations/20241115115952_add_venue_relation_to_events/migration.sql b/server/prisma/migrations/20241115115952_add_venue_relation_to_events/migration.sql new file mode 100644 index 0000000..93a896c --- /dev/null +++ b/server/prisma/migrations/20241115115952_add_venue_relation_to_events/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - You are about to drop the column `location` on the `Event` table. All the data in the column will be lost. + - Added the required column `venueId` to the `Event` table without a default value. This is not possible if the table is not empty. + - Added the required column `coordinates` to the `Venue` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Event" DROP COLUMN "location", +ADD COLUMN "venueId" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "Venue" ADD COLUMN "coordinates" JSONB NOT NULL; + +-- AddForeignKey +ALTER TABLE "Event" ADD CONSTRAINT "Event_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 470dc20..9956e90 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -26,8 +26,9 @@ model Event { description String startDate DateTime endDate DateTime - location String status String @default("draft") // draft, published, cancelled + venue Venue @relation(fields: [venueId], references: [id]) + venueId Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -40,6 +41,7 @@ model Venue { contact Json // {phone, email, website} images String[] // Array of image URLs coordinates Json // {lat: number, lng: number} + events Event[] // Add this relation field createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index e438ac6..76802ca 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -179,47 +179,33 @@ async function main() { prisma.event.create({ data: { title: "Jazz Night at Blue Note", - description: - "Live jazz quartet performing classic standards and original compositions. Perfect for a sophisticated evening out.", + description: "Live jazz quartet performing classic standards and original compositions.", startDate: new Date("2024-03-25T19:00:00Z"), endDate: new Date("2024-03-25T23:00:00Z"), - location: "Blue Note Bar & Restaurant", status: "published", - }, + venueId: venues[0].id // Blue Note Jazz Club + } }), prisma.event.create({ data: { - title: "Acoustic Sessions at The Old Pub", - description: - "Local singer-songwriters showcase their original music in an intimate setting. Great craft beer selection available.", + title: "Acoustic Sessions", + description: "Local singer-songwriters showcase their original music in an intimate setting.", startDate: new Date("2024-03-28T20:00:00Z"), endDate: new Date("2024-03-29T00:00:00Z"), - location: "The Old Pub", status: "published", - }, + venueId: venues[1].id // The Basement Bar + } }), prisma.event.create({ data: { - title: "Latin Night at Casa Bonita", - description: - "Live salsa band and dance lessons. Featuring authentic Latin cuisine and signature cocktails.", + title: "Flamenco Night", + description: "Live flamenco performance with traditional Spanish music and dance.", startDate: new Date("2024-03-30T21:00:00Z"), endDate: new Date("2024-03-31T02:00:00Z"), - location: "Casa Bonita Restaurant & Bar", status: "published", - }, - }), - prisma.event.create({ - data: { - title: "Rock Cover Band at Murphy's", - description: - "Local favorites 'The Amplifiers' playing classic rock hits from the 70s to now. Full bar and pub menu available.", - startDate: new Date("2024-04-01T20:30:00Z"), - endDate: new Date("2024-04-02T00:30:00Z"), - location: "Murphy's Irish Pub", - status: "draft", - }, - }), + venueId: venues[2].id // Flamenco Casa + } + }) ]); console.log(`Database has been seeded with ${events.length} events 🌱`); diff --git a/server/src/controllers/event.controller.ts b/server/src/controllers/event.controller.ts index 90c9ce3..e4090d4 100644 --- a/server/src/controllers/event.controller.ts +++ b/server/src/controllers/event.controller.ts @@ -6,7 +6,7 @@ import { EventInput, EventUpdateInput, EventParams, - EventStatus, + EventGeoJSONFeature } from "../types/event.types"; import { ApiResponse } from "../types/api.types"; @@ -24,8 +24,14 @@ export class EventController { description: event.description, startDate: event.startDate.toISOString(), endDate: event.endDate.toISOString(), - location: event.location, - status: event.status as keyof typeof EventStatus, + status: event.status as EventResponse["status"], + venueId: event.venueId, + venue: event.venue && { + id: event.venue.id, + name: event.venue.name, + address: event.venue.address, + coordinates: event.venue.coordinates as { lat: number; lng: number } + }, createdAt: event.createdAt.toISOString(), updatedAt: event.updatedAt.toISOString(), }, @@ -56,8 +62,14 @@ export class EventController { description: event.description, startDate: event.startDate.toISOString(), endDate: event.endDate.toISOString(), - location: event.location, - status: event.status as keyof typeof EventStatus, + status: event.status as EventResponse["status"], + venueId: event.venueId, + venue: event.venue && { + id: event.venue.id, + name: event.venue.name, + address: event.venue.address, + coordinates: event.venue.coordinates as { lat: number; lng: number } + }, createdAt: event.createdAt.toISOString(), updatedAt: event.updatedAt.toISOString(), })), @@ -89,8 +101,14 @@ export class EventController { description: event.description, startDate: event.startDate.toISOString(), endDate: event.endDate.toISOString(), - location: event.location, - status: event.status as keyof typeof EventStatus, + status: event.status as EventResponse["status"], + venueId: event.venueId, + venue: event.venue && { + id: event.venue.id, + name: event.venue.name, + address: event.venue.address, + coordinates: event.venue.coordinates as { lat: number; lng: number } + }, createdAt: event.createdAt.toISOString(), updatedAt: event.updatedAt.toISOString(), }, @@ -124,8 +142,14 @@ export class EventController { description: event.description, startDate: event.startDate.toISOString(), endDate: event.endDate.toISOString(), - location: event.location, - status: event.status as keyof typeof EventStatus, + status: event.status as EventResponse["status"], + venueId: event.venueId, + venue: event.venue && { + id: event.venue.id, + name: event.venue.name, + address: event.venue.address, + coordinates: event.venue.coordinates as { lat: number; lng: number } + }, createdAt: event.createdAt.toISOString(), updatedAt: event.updatedAt.toISOString(), }, @@ -162,8 +186,14 @@ export class EventController { description: event.description, startDate: event.startDate.toISOString(), endDate: event.endDate.toISOString(), - location: event.location, - status: event.status as keyof typeof EventStatus, + status: event.status as EventResponse["status"], + venueId: event.venueId, + venue: event.venue && { + id: event.venue.id, + name: event.venue.name, + address: event.venue.address, + coordinates: event.venue.coordinates as { lat: number; lng: number } + }, createdAt: event.createdAt.toISOString(), updatedAt: event.updatedAt.toISOString(), }, @@ -209,4 +239,55 @@ export class EventController { res.status(statusCode).json(response); } } + + static async getGeoJson(req: Request, res: Response) { + try { + const id = Number(req.params.id); + const event = await EventService.findById(id); + + if (!event.venue) { + throw new Error("Event venue not found"); + } + + const geoJsonResponse: ApiResponse = { + status: "success", + data: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [ + (event.venue.coordinates as { lng: number; lat: number }).lng, + (event.venue.coordinates as { lng: number; lat: number }).lat + ] + }, + properties: { + id: event.id, + title: event.title, + description: event.description, + startDate: event.startDate.toISOString(), + endDate: event.endDate.toISOString(), + status: event.status as EventResponse["status"], + venue: { + id: event.venue.id, + name: event.venue.name, + address: event.venue.address + }, + createdAt: event.createdAt.toISOString(), + updatedAt: event.updatedAt.toISOString() + } + }, + timestamp: new Date().toISOString() + }; + + res.json(geoJsonResponse); + } catch (error) { + const response: ApiResponse = { + status: "error", + message: error instanceof Error ? error.message : "Event not found", + timestamp: new Date().toISOString() + }; + + res.status(404).json(response); + } + } } diff --git a/server/src/routes/event.routes.ts b/server/src/routes/event.routes.ts index 81636c1..23b3e50 100644 --- a/server/src/routes/event.routes.ts +++ b/server/src/routes/event.routes.ts @@ -39,4 +39,10 @@ router.delete("/:id", EventController.delete ); +// Get event in GeoJSON format +router.get("/:id/geojson", + validateRequest.params(EventParamsSchema), + EventController.getGeoJson +); + export default router; \ No newline at end of file diff --git a/server/src/services/event.service.ts b/server/src/services/event.service.ts index b0b25d2..8cb510f 100644 --- a/server/src/services/event.service.ts +++ b/server/src/services/event.service.ts @@ -6,6 +6,9 @@ export class EventService { static async findById(id: number) { const event = await prisma.event.findUnique({ where: { id }, + include: { + venue: true + } }); if (!event) { @@ -27,6 +30,9 @@ export class EventService { skip, take: limit, orderBy: orderBy ? { [orderBy]: order || 'asc' } : undefined, + include: { + venue: true + } }), prisma.event.count({ where }), ]); @@ -52,11 +58,17 @@ export class EventService { startDate: new Date(data.startDate), endDate: new Date(data.endDate), }, + include: { + venue: true + } }); return event; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2003") { + throw new Error("Venue not found"); + } throw new Error(`Failed to create event: ${error.message}`); } throw error; @@ -65,16 +77,19 @@ export class EventService { static async update(id: string, data: EventUpdateInput) { try { - const userId = Number(id); - if (isNaN(userId)) throw new Error('Invalid event ID'); + const eventId = Number(id); + if (isNaN(eventId)) throw new Error('Invalid event ID'); const event = await prisma.event.update({ - where: { id: userId }, + where: { id: eventId }, data: { ...data, startDate: data.startDate ? new Date(data.startDate) : undefined, endDate: data.endDate ? new Date(data.endDate) : undefined, }, + include: { + venue: true + } }); return event; @@ -83,6 +98,9 @@ export class EventService { if (error.code === "P2025") { throw new Error("Event not found"); } + if (error.code === "P2003") { + throw new Error("Venue not found"); + } throw new Error(`Failed to update event: ${error.message}`); } throw error; @@ -101,6 +119,9 @@ export class EventService { startDate: new Date(data.startDate), endDate: new Date(data.endDate), }, + include: { + venue: true + } }); return event; @@ -109,6 +130,9 @@ export class EventService { if (error.code === "P2025") { throw new Error("Event not found"); } + if (error.code === "P2003") { + throw new Error("Venue not found"); + } throw new Error(`Failed to replace event: ${error.message}`); } throw error; diff --git a/server/src/types/event.types.ts b/server/src/types/event.types.ts index 9134d34..eaf21a3 100644 --- a/server/src/types/event.types.ts +++ b/server/src/types/event.types.ts @@ -13,9 +13,9 @@ export const EventSchema = z.object({ description: z.string().min(1, "Description is required").max(1000), startDate: z.string().datetime(), // ISO 8601 format endDate: z.string().datetime(), // ISO 8601 format - location: z.string().min(1, "Location is required").max(200), status: z.enum([EventStatus.DRAFT, EventStatus.PUBLISHED, EventStatus.CANCELLED]) - .default(EventStatus.DRAFT) + .default(EventStatus.DRAFT), + venueId: z.number().positive("Venue ID is required") }); // Schema for PATCH operations - all fields are optional @@ -50,8 +50,41 @@ export type EventResponse = { description: string; startDate: string; endDate: string; - location: string; status: keyof typeof EventStatus; + venueId: number; + venue?: { + id: number; + name: string; + address: string; + coordinates: { + lat: number; + lng: number; + }; + }; createdAt: string; updatedAt: string; +}; + +// GeoJSON types for events +export type EventGeoJSONFeature = { + type: 'Feature'; + geometry: { + type: 'Point'; + coordinates: [number, number]; // [longitude, latitude] from venue + }; + properties: { + id: number; + title: string; + description: string; + startDate: string; + endDate: string; + status: keyof typeof EventStatus; + venue: { + id: number; + name: string; + address: string; + }; + createdAt: string; + updatedAt: string; + }; }; \ No newline at end of file From c6e798fc75420cac06dc2d2c042a9f5f24d0c6ea Mon Sep 17 00:00:00 2001 From: Morais-C Date: Fri, 15 Nov 2024 13:48:41 +0000 Subject: [PATCH 4/4] feat(events): implement zone search with distance calculations - Add zone search endpoint - GET /api/events/zone with lat/lng/radius parameters - Optional filters for startDate and status - Return events sorted by distance - Include search center and radius in response - Implement geospatial features - Add Haversine formula for distance calculations - Sort results by distance from center - Round distances to 2 decimal places - Handle coordinate transformations - Create realistic test data - Add 6 venues in New York (20km radius) - Add 5 venues in Paris (15km radius) - Add 4 venues in Berlin (10km radius) - Add 2-3 events per venue - Use real-world coordinates - Update documentation - Create zone-search-testing.md guide - Document coordinate formats - Add example requests for each city - Include test scenarios Technical notes: - JavaScript-based distance calculations - GeoJSON FeatureCollection response format - Coordinate validation (-90/90 lat, -180/180 lng) - Maximum radius of 50km Part of Event Zone Search feature - Step 4/4 --- .vscode/settings.json | 1 + .../2024.11.15_Approach_to_EventZoneSearch.md | 51 +- docs/zone-search-testing.md | 142 ++++++ postman/README.md | 57 +++ .../v52-tier3-team-34.postman_collection.json | 86 ++++ server/prisma/seed.ts | 448 ++++++++++++------ server/src/controllers/event.controller.ts | 76 ++- server/src/routes/event.routes.ts | 18 +- server/src/services/event.service.ts | 43 ++ server/src/types/event.types.ts | 78 ++- 10 files changed, 804 insertions(+), 196 deletions(-) create mode 100644 docs/zone-search-testing.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 227a22d..d135bc0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,7 @@ "Cristiano", "Damilola", "datasource", + "datetime", "geospatial", "Honvedo", "Hoxton", diff --git a/docs/2024.11.15_Approach_to_EventZoneSearch.md b/docs/2024.11.15_Approach_to_EventZoneSearch.md index 924b113..ce345e3 100644 --- a/docs/2024.11.15_Approach_to_EventZoneSearch.md +++ b/docs/2024.11.15_Approach_to_EventZoneSearch.md @@ -108,27 +108,46 @@ Implementation of geospatial search functionality for live music events, includi - Follow GeoJSON standards - Consider error scenarios -### Step 4: Implement Zone Search +### Step 4: Implement Zone Search ✅ 1. **Code Changes** - - Add geospatial query endpoint - - Implement GeoJSON responses - - Add distance calculations - - Create zone search logic + - Added zone search endpoint to Event API + - Implemented Haversine formula for distance calculations + - Created GeoJSON FeatureCollection response + - Added filtering by date and status + - Included distance in event properties 2. **Testing** - - Add zone search to Postman - - Test radius searches - - Verify distance calculations + - Created realistic test data: + - 6 venues in New York (20km radius) + - 5 venues in Paris (15km radius) + - 4 venues in Berlin (10km radius) + - Added 2-3 events per venue + - Tested different search radiuses + - Verified distance calculations 3. **Documentation** - - Document zone search endpoint - - Add GeoJSON response examples - - Include query parameters - -4. **Commit** - - Message: "feat: add event zone search" - - Detail search parameters - - List response format + - Created zone-search-testing.md guide + - Added example requests for each city + - Documented coordinate formats + - Included test scenarios + +4. **Technical Details** + - JavaScript-based distance calculation + - Sorted results by distance + - Rounded distances to 2 decimals + - Included search center in response + +5. **Key Learnings** + - Importance of realistic test data + - Need for proper coordinate validation + - Benefits of GeoJSON standard + - Performance considerations for distance calculations + +6. **Future Considerations** + - Potential PostGIS migration path + - Index optimization for larger datasets + - Additional spatial query features + - Performance monitoring needs ## Final Deliverables diff --git a/docs/zone-search-testing.md b/docs/zone-search-testing.md new file mode 100644 index 0000000..1fda6fd --- /dev/null +++ b/docs/zone-search-testing.md @@ -0,0 +1,142 @@ +# Zone Search Testing Guide + +## Seed Data Overview + +### New York Venues (Times Square as center: 40.7580, -73.9855) +1. **Blue Note Jazz Club** + - Location: 40.7302, -74.0003 + - Address: 131 W 3rd St, New York, NY 10012 + - Events: 2 events (Jazz Night, Blues Evening) + +2. **Village Vanguard** + - Location: 40.7347, -74.0023 + - Address: 178 7th Ave S, New York, NY 10014 + - Events: 2 events (Jazz Night, Blues Evening) + +3. **Birdland Jazz Club** + - Location: 40.7589, -73.9910 + - Address: 315 W 44th St, New York, NY 10036 + - Events: 2 events (Jazz Night, Blues Evening) + +4. **The Iridium** + - Location: 40.7620, -73.9837 + - Address: 1650 Broadway, New York, NY 10019 + - Events: 2 events (Jazz Night, Blues Evening) + +5. **Bowery Ballroom** + - Location: 40.7204, -73.9934 + - Address: 6 Delancey St, New York, NY 10002 + - Events: 2 events (Jazz Night, Blues Evening) + +6. **Brooklyn Steel** + - Location: 40.7168, -73.9396 + - Address: 319 Frost St, Brooklyn, NY 11222 + - Events: 2 events (Jazz Night, Blues Evening) + +### Paris Venues (Notre-Dame as center: 48.8530, 2.3499) +1. **L'Olympia** + - Location: 48.8700, 2.3283 + - Address: 28 Boulevard des Capucines, 75009 Paris + - Events: 2 events (Soirée Jazz, Classical Night) + +2. **Le Bataclan** + - Location: 48.8632, 2.3702 + - Address: 50 Boulevard Voltaire, 75011 Paris + - Events: 2 events (Soirée Jazz, Classical Night) + +3. **New Morning** + - Location: 48.8729, 2.3502 + - Address: 7-9 Rue des Petites Écuries, 75010 Paris + - Events: 2 events (Soirée Jazz, Classical Night) + +4. **La Cigale** + - Location: 48.8827, 2.3401 + - Address: 120 Boulevard de Rochechouart, 75018 Paris + - Events: 2 events (Soirée Jazz, Classical Night) + +5. **Le Petit Journal** + - Location: 48.8524, 2.3384 + - Address: 71 Boulevard Saint-Germain, 75006 Paris + - Events: 2 events (Soirée Jazz, Classical Night) + +### Berlin Venues (Brandenburg Gate as center: 52.5163, 13.3777) +1. **Berghain** + - Location: 52.5111, 13.4399 + - Address: Am Wriezener Bahnhof, 10243 Berlin + - Events: 3 events (Electronic Night, Indie Rock, Experimental Music) + +2. **SO36** + - Location: 52.5001, 13.4285 + - Address: Oranienstraße 190, 10999 Berlin + - Events: 3 events (Electronic Night, Indie Rock, Experimental Music) + +3. **Lido** + - Location: 52.4977, 13.4422 + - Address: Cuvrystraße 7, 10997 Berlin + - Events: 3 events (Electronic Night, Indie Rock, Experimental Music) + +4. **Astra Kulturhaus** + - Location: 52.5066, 13.4542 + - Address: Revaler Str. 99, 10245 Berlin + - Events: 3 events (Electronic Night, Indie Rock, Experimental Music) + +## Testing Zone Search + +### Example Requests + +1. **New York Events (20km radius)** +``` +GET http://localhost:3000/api/events/zone?lat=40.7580&lng=-73.9855&radius=20 +``` +Expected: All Manhattan and Brooklyn venues' events + +2. **Paris Events (15km radius)** +``` +GET http://localhost:3000/api/events/zone?lat=48.8530&lng=2.3499&radius=15 +``` +Expected: All Paris venues' events + +3. **Berlin Events (10km radius)** +``` +GET http://localhost:3000/api/events/zone?lat=52.5163&lng=13.3777&radius=10 +``` +Expected: All Berlin venues' events + +### Testing with Browser + +1. **Using Browser Developer Tools** + - Open browser developer tools (F12) + - Go to Network tab + - Copy and paste the URL with parameters + - Observe the GeoJSON response + +2. **Using Query Parameters** + - `lat`: Latitude of search center + - `lng`: Longitude of search center + - `radius`: Search radius in kilometers (max 50) + - Optional: + - `startDate`: Filter events after this date + - `status`: Filter by event status + +3. **Example with Filters** +``` +http://localhost:3000/api/events/zone?lat=40.7580&lng=-73.9855&radius=20&status=published&startDate=2024-03-20T00:00:00Z +``` + +### Expected Results + +1. **Distance Calculations** + - Events are sorted by distance from search center + - Distance is included in properties (in kilometers) + - Rounded to 2 decimal places + +2. **GeoJSON Format** + - FeatureCollection with venue points + - Coordinates in [longitude, latitude] order + - Properties include event and venue details + +3. **Response Structure** + - Search center coordinates + - Search radius + - List of events within radius + - Distance from center for each event \ No newline at end of file diff --git a/postman/README.md b/postman/README.md index 9f342af..e04b925 100644 --- a/postman/README.md +++ b/postman/README.md @@ -510,6 +510,63 @@ Current variables: } ``` +#### Get Events in Zone +- **Endpoint**: GET `/api/events/zone` +- **Query Parameters**: + - lat: number (required, -90 to 90) - Latitude of search center + - lng: number (required, -180 to 180) - Longitude of search center + - radius: number (required, max 50) - Search radius in kilometers + - startDate: string (optional) - Filter events starting after this time + - status: string (optional, 'draft'|'published'|'cancelled') - Filter by event status +- **Success Response** (200): + ```json + { + "status": "success", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-0.082448, 51.527817] // [longitude, latitude] + }, + "properties": { + "id": 2, + "title": "Acoustic Sessions", + "description": "Local singer-songwriters showcase...", + "startDate": "2024-03-28T20:00:00.000Z", + "endDate": "2024-03-29T00:00:00.000Z", + "status": "published", + "distance": 2.8, // Distance in kilometers from search center + "venue": { + "id": 2, + "name": "The Basement Bar", + "address": "42 Hoxton Square, London N1 6PB" + }, + "createdAt": "2024-03-20T10:00:00.000Z", + "updatedAt": "2024-03-20T10:00:00.000Z" + } + } + ], + "center": { + "type": "Point", + "coordinates": [-0.118, 51.509] // Search center [longitude, latitude] + }, + "radius": 5 // Search radius in kilometers + }, + "timestamp": "2024-03-20T10:00:00.000Z" + } + ``` +- **Error Response** (400): + ```json + { + "status": "error", + "message": "Invalid coordinates or radius", + "timestamp": "2024-03-20T10:00:00.000Z" + } + ``` + ### Venues #### List Venues diff --git a/postman/v52-tier3-team-34.postman_collection.json b/postman/v52-tier3-team-34.postman_collection.json index dc436be..498ccc6 100644 --- a/postman/v52-tier3-team-34.postman_collection.json +++ b/postman/v52-tier3-team-34.postman_collection.json @@ -622,6 +622,92 @@ } } ] + }, + { + "name": "Get Events in Zone", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/events/zone?lat=51.509&lng=-0.118&radius=5", + "host": ["{{baseUrl}}"], + "path": ["api", "events", "zone"], + "query": [ + { + "key": "lat", + "value": "51.509", + "description": "Latitude of search center" + }, + { + "key": "lng", + "value": "-0.118", + "description": "Longitude of search center" + }, + { + "key": "radius", + "value": "5", + "description": "Search radius in kilometers" + }, + { + "key": "startDate", + "value": "2024-03-20T00:00:00Z", + "description": "Filter events starting after this time", + "disabled": true + }, + { + "key": "status", + "value": "published", + "description": "Filter by event status", + "disabled": true + } + ] + }, + "description": "Get events within a specified radius of coordinates" + }, + "response": [ + { + "name": "Success Response", + "status": "OK", + "code": 200, + "body": { + "status": "success", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-0.082448, 51.527817] + }, + "properties": { + "id": 2, + "title": "Acoustic Sessions", + "description": "Local singer-songwriters showcase...", + "startDate": "2024-03-28T20:00:00.000Z", + "endDate": "2024-03-29T00:00:00.000Z", + "status": "published", + "distance": 2.8, + "venue": { + "id": 2, + "name": "The Basement Bar", + "address": "42 Hoxton Square, London N1 6PB" + }, + "createdAt": "2024-03-20T10:00:00.000Z", + "updatedAt": "2024-03-20T10:00:00.000Z" + } + } + ], + "center": { + "type": "Point", + "coordinates": [-0.118, 51.509] + }, + "radius": 5 + }, + "timestamp": "2024-03-20T10:00:00.000Z" + } + } + ] } ] }, diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index 76802ca..79c7c70 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -4,214 +4,350 @@ const prisma = new PrismaClient(); async function main() { // Clear existing data - await prisma.venue.deleteMany(); await prisma.event.deleteMany(); - await prisma.user.deleteMany(); + await prisma.venue.deleteMany(); - // Create test venues - const venues = await Promise.all([ + // New York Venues (Times Square as center: 40.7580, -73.9855) + const nyVenues = await Promise.all([ prisma.venue.create({ data: { name: "Blue Note Jazz Club", - description: "Historic jazz venue featuring nightly live performances and full bar service.", + description: "Historic jazz venue featuring nightly live performances and full bar service", address: "131 W 3rd St, New York, NY 10012", contact: { phone: "+1-212-475-8592", email: "info@bluenote.net", - website: "https://www.bluenotejazz.com" - }, - coordinates: { - lat: 40.730483, - lng: -74.000339 + website: "https://www.bluenote.com" }, - images: [ - "https://example.com/venues/bluenote1.jpg", - "https://example.com/venues/bluenote2.jpg" - ] + coordinates: { lat: 40.7302, lng: -74.0003 }, + images: ["https://example.com/venues/bluenote1.jpg"] } }), prisma.venue.create({ data: { - name: "The Basement Bar", - description: "Intimate underground venue for live music and craft cocktails.", - address: "42 Hoxton Square, London N1 6PB", + name: "Village Vanguard", + description: "Legendary jazz club in Greenwich Village, operating since 1935", + address: "178 7th Ave S, New York, NY 10014", contact: { - phone: "+44-20-7729-4121", - email: "bookings@basementbar.co.uk", - website: "https://www.thebasementbar.co.uk" + phone: "+1-212-255-4037", + email: "info@villagevanguard.com", + website: "https://www.villagevanguard.com" }, - coordinates: { - lat: 51.527817, - lng: -0.082448 - }, - images: [ - "https://example.com/venues/basement1.jpg" - ] + coordinates: { lat: 40.7347, lng: -74.0023 }, + images: ["https://example.com/venues/vanguard1.jpg"] } }), prisma.venue.create({ data: { - name: "Flamenco Casa", - description: "Authentic Spanish tablao featuring nightly flamenco shows.", - address: "Calle de los Reyes, 28015 Madrid", + name: "Birdland Jazz Club", + description: "Premier jazz venue featuring world-class musicians", + address: "315 W 44th St, New York, NY 10036", contact: { - phone: "+34-91-547-2672", - email: "hola@flamencacasa.es", - website: "https://www.flamencacasa.es" - }, - coordinates: { - lat: 40.423697, - lng: -3.710432 + phone: "+1-212-581-3080", + email: "info@birdlandjazz.com", + website: "https://www.birdlandjazz.com" }, - images: [ - "https://example.com/venues/flamenco1.jpg", - "https://example.com/venues/flamenco2.jpg", - "https://example.com/venues/flamenco3.jpg" - ] + coordinates: { lat: 40.7589, lng: -73.9910 }, + images: ["https://example.com/venues/birdland1.jpg"] } - }) - ]); - - // Create test users - const users = await Promise.all([ - // Developers - prisma.user.create({ - data: { - email: "john.dev@example.com", - name: "John Developer", - googleId: "google_dev_123", - profileImage: "https://example.com/avatars/john.jpg", - }, }), - prisma.user.create({ + prisma.venue.create({ data: { - email: "jane.tech@example.com", - name: "Jane Tech", - googleId: "google_dev_456", - profileImage: "https://example.com/avatars/jane.jpg", - }, + name: "The Iridium", + description: "Intimate music venue known for jazz and rock performances", + address: "1650 Broadway, New York, NY 10019", + contact: { + phone: "+1-212-582-2121", + email: "info@theiridium.com", + website: "https://www.theiridium.com" + }, + coordinates: { lat: 40.7620, lng: -73.9837 }, + images: ["https://example.com/venues/iridium1.jpg"] + } }), - - // Designers - prisma.user.create({ + prisma.venue.create({ data: { - email: "bob.design@example.com", - name: "Bob Designer", - googleId: "google_design_789", - profileImage: "https://example.com/avatars/bob.jpg", - }, + name: "Bowery Ballroom", + description: "Three-level music venue featuring indie and rock acts", + address: "6 Delancey St, New York, NY 10002", + contact: { + phone: "+1-212-533-2111", + email: "info@boweryballroom.com", + website: "https://www.boweryballroom.com" + }, + coordinates: { lat: 40.7204, lng: -73.9934 }, + images: ["https://example.com/venues/bowery1.jpg"] + } }), - prisma.user.create({ + prisma.venue.create({ data: { - email: "alice.ux@example.com", - name: "Alice UX", - googleId: "google_design_101", - profileImage: "https://example.com/avatars/alice.jpg", - }, - }), + name: "Brooklyn Steel", + description: "Modern music venue in East Williamsburg", + address: "319 Frost St, Brooklyn, NY 11222", + contact: { + phone: "+1-929-234-6200", + email: "info@bksteel.com", + website: "https://www.brooklynsteel.com" + }, + coordinates: { lat: 40.7168, lng: -73.9396 }, + images: ["https://example.com/venues/bksteel1.jpg"] + } + }) + ]); - // Project Managers - prisma.user.create({ - data: { - email: "sarah.pm@example.com", - name: "Sarah Manager", - googleId: "google_pm_102", - profileImage: "https://example.com/avatars/sarah.jpg", - }, - }), - prisma.user.create({ + // Paris Venues (Notre-Dame as center: 48.8530, 2.3499) + const parisVenues = await Promise.all([ + prisma.venue.create({ data: { - email: "mike.lead@example.com", - name: "Mike Team Lead", - googleId: "google_pm_103", - profileImage: "https://example.com/avatars/mike.jpg", - }, + name: "L'Olympia", + description: "Historic music hall hosting major international artists", + address: "28 Boulevard des Capucines, 75009 Paris", + contact: { + phone: "+33-1-47-42-25-49", + email: "contact@olympia.fr", + website: "https://www.olympiahall.com" + }, + coordinates: { lat: 48.8700, lng: 2.3283 }, + images: ["https://example.com/venues/olympia1.jpg"] + } }), - - // Marketing Team - prisma.user.create({ + prisma.venue.create({ data: { - email: "emma.marketing@example.com", - name: "Emma Marketing", - googleId: "google_marketing_104", - profileImage: "https://example.com/avatars/emma.jpg", - }, + name: "Le Bataclan", + description: "Iconic venue for rock and contemporary music", + address: "50 Boulevard Voltaire, 75011 Paris", + contact: { + phone: "+33-1-43-14-00-30", + email: "info@bataclan.fr", + website: "https://www.bataclan.fr" + }, + coordinates: { lat: 48.8632, lng: 2.3702 }, + images: ["https://example.com/venues/bataclan1.jpg"] + } }), - prisma.user.create({ + prisma.venue.create({ data: { - email: "david.content@example.com", - name: "David Content", - googleId: "google_marketing_105", - }, + name: "New Morning", + description: "Renowned jazz club featuring international artists", + address: "7-9 Rue des Petites Écuries, 75010 Paris", + contact: { + phone: "+33-1-45-23-51-41", + email: "contact@newmorning.com", + website: "https://www.newmorning.com" + }, + coordinates: { lat: 48.8729, lng: 2.3502 }, + images: ["https://example.com/venues/newmorning1.jpg"] + } }), - - // QA Team - prisma.user.create({ + prisma.venue.create({ data: { - email: "tom.qa@example.com", - name: "Tom Tester", - googleId: "google_qa_106", - profileImage: "https://example.com/avatars/tom.jpg", - }, + name: "La Cigale", + description: "Historic theater venue for diverse musical performances", + address: "120 Boulevard de Rochechouart, 75018 Paris", + contact: { + phone: "+33-1-49-25-89-99", + email: "contact@lacigale.fr", + website: "https://www.lacigale.fr" + }, + coordinates: { lat: 48.8827, lng: 2.3401 }, + images: ["https://example.com/venues/cigale1.jpg"] + } }), - prisma.user.create({ + prisma.venue.create({ data: { - email: "lisa.qa@example.com", - name: "Lisa QA", - googleId: "google_qa_107", - profileImage: "https://example.com/avatars/lisa.jpg", - }, - }), + name: "Le Petit Journal", + description: "Intimate jazz club with nightly performances", + address: "71 Boulevard Saint-Germain, 75006 Paris", + contact: { + phone: "+33-1-43-26-28-59", + email: "info@petitjournal.com", + website: "https://www.petitjournal.com" + }, + coordinates: { lat: 48.8524, lng: 2.3384 }, + images: ["https://example.com/venues/petitjournal1.jpg"] + } + }) ]); - console.log(`Database has been seeded with:`); - console.log(`- ${venues.length} venues`); - venues.forEach(venue => { - console.log(` - ${venue.name} (ID: ${venue.id})`); - }); - console.log(`- ${users.length} users`); - users.forEach(user => { - console.log(` - ${user.name} (ID: ${user.id})`); - }); - - // Create sample events - const events = await Promise.all([ - prisma.event.create({ + // Berlin Venues (Brandenburg Gate as center: 52.5163, 13.3777) + const berlinVenues = await Promise.all([ + prisma.venue.create({ data: { - title: "Jazz Night at Blue Note", - description: "Live jazz quartet performing classic standards and original compositions.", - startDate: new Date("2024-03-25T19:00:00Z"), - endDate: new Date("2024-03-25T23:00:00Z"), - status: "published", - venueId: venues[0].id // Blue Note Jazz Club + name: "Berghain", + description: "World-famous electronic music venue", + address: "Am Wriezener Bahnhof, 10243 Berlin", + contact: { + phone: "+49-30-2936-0210", + email: "info@berghain.de", + website: "https://www.berghain.de" + }, + coordinates: { lat: 52.5111, lng: 13.4399 }, + images: ["https://example.com/venues/berghain1.jpg"] } }), - prisma.event.create({ + prisma.venue.create({ data: { - title: "Acoustic Sessions", - description: "Local singer-songwriters showcase their original music in an intimate setting.", - startDate: new Date("2024-03-28T20:00:00Z"), - endDate: new Date("2024-03-29T00:00:00Z"), - status: "published", - venueId: venues[1].id // The Basement Bar + name: "SO36", + description: "Historic punk and rock venue in Kreuzberg", + address: "Oranienstraße 190, 10999 Berlin", + contact: { + phone: "+49-30-6140-1306", + email: "info@so36.de", + website: "https://www.so36.de" + }, + coordinates: { lat: 52.5001, lng: 13.4285 }, + images: ["https://example.com/venues/so361.jpg"] } }), - prisma.event.create({ + prisma.venue.create({ data: { - title: "Flamenco Night", - description: "Live flamenco performance with traditional Spanish music and dance.", - startDate: new Date("2024-03-30T21:00:00Z"), - endDate: new Date("2024-03-31T02:00:00Z"), - status: "published", - venueId: venues[2].id // Flamenco Casa + name: "Lido", + description: "Popular indie music venue", + address: "Cuvrystraße 7, 10997 Berlin", + contact: { + phone: "+49-30-6956-6840", + email: "info@lido-berlin.de", + website: "https://www.lido-berlin.de" + }, + coordinates: { lat: 52.4977, lng: 13.4422 }, + images: ["https://example.com/venues/lido1.jpg"] + } + }), + prisma.venue.create({ + data: { + name: "Astra Kulturhaus", + description: "Alternative music venue in Friedrichshain", + address: "Revaler Str. 99, 10245 Berlin", + contact: { + phone: "+49-30-2005-6767", + email: "info@astra-berlin.de", + website: "https://www.astra-berlin.de" + }, + coordinates: { lat: 52.5066, lng: 13.4542 }, + images: ["https://example.com/venues/astra1.jpg"] } }) ]); - console.log(`Database has been seeded with ${events.length} events 🌱`); - events.forEach((event) => { - console.log(`Created event: ${event.title} (ID: ${event.id})`); - }); + // Create events for each venue + const events = []; + + // Helper function to create event dates + const getEventDates = (dayOffset: number) => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() + dayOffset); + startDate.setHours(20, 0, 0, 0); + const endDate = new Date(startDate); + endDate.setHours(23, 0, 0, 0); + return { startDate, endDate }; + }; + + // Events for New York venues + for (const venue of nyVenues) { + // 2-3 events per venue on different days + const dates1 = getEventDates(1); + const dates2 = getEventDates(3); + + events.push( + prisma.event.create({ + data: { + title: `Jazz Night at ${venue.name}`, + description: "Live jazz quartet performing classic standards and original compositions", + startDate: dates1.startDate, + endDate: dates1.endDate, + status: "published", + venueId: venue.id + } + }), + prisma.event.create({ + data: { + title: `Blues Evening at ${venue.name}`, + description: "Soulful blues performance featuring local and guest artists", + startDate: dates2.startDate, + endDate: dates2.endDate, + status: "published", + venueId: venue.id + } + }) + ); + } + + // Events for Paris venues + for (const venue of parisVenues) { + const dates1 = getEventDates(2); + const dates2 = getEventDates(4); + + events.push( + prisma.event.create({ + data: { + title: `Soirée Jazz at ${venue.name}`, + description: "Une soirée exceptionnelle de jazz contemporain", + startDate: dates1.startDate, + endDate: dates1.endDate, + status: "published", + venueId: venue.id + } + }), + prisma.event.create({ + data: { + title: `Classical Night at ${venue.name}`, + description: "Classical music performance featuring chamber orchestra", + startDate: dates2.startDate, + endDate: dates2.endDate, + status: "published", + venueId: venue.id + } + }) + ); + } + + // Events for Berlin venues + for (const venue of berlinVenues) { + const dates1 = getEventDates(1); + const dates2 = getEventDates(3); + const dates3 = getEventDates(5); + + events.push( + prisma.event.create({ + data: { + title: `Electronic Night at ${venue.name}`, + description: "Progressive electronic music featuring international DJs", + startDate: dates1.startDate, + endDate: dates1.endDate, + status: "published", + venueId: venue.id + } + }), + prisma.event.create({ + data: { + title: `Indie Rock at ${venue.name}`, + description: "Alternative and indie rock bands showcase", + startDate: dates2.startDate, + endDate: dates2.endDate, + status: "published", + venueId: venue.id + } + }), + prisma.event.create({ + data: { + title: `Experimental Music at ${venue.name}`, + description: "Avant-garde and experimental music performance", + startDate: dates3.startDate, + endDate: dates3.endDate, + status: "draft", + venueId: venue.id + } + }) + ); + } + + const createdEvents = await Promise.all(events); + + console.log(`Database has been seeded with:`); + console.log(`- ${nyVenues.length} New York venues`); + console.log(`- ${parisVenues.length} Paris venues`); + console.log(`- ${berlinVenues.length} Berlin venues`); + console.log(`- ${createdEvents.length} events`); } main() diff --git a/server/src/controllers/event.controller.ts b/server/src/controllers/event.controller.ts index e4090d4..4643a2a 100644 --- a/server/src/controllers/event.controller.ts +++ b/server/src/controllers/event.controller.ts @@ -6,7 +6,10 @@ import { EventInput, EventUpdateInput, EventParams, - EventGeoJSONFeature + EventGeoJSONFeature, + EventZoneQuery, + EventZoneResponse, + EventZoneQueryCoerced } from "../types/event.types"; import { ApiResponse } from "../types/api.types"; @@ -290,4 +293,75 @@ export class EventController { res.status(404).json(response); } } + + static async findInZone( + req: Request<{}, {}, {}, EventZoneQueryCoerced>, + res: Response + ) { + try { + const { lat, lng, radius, startDate, status } = req.query; + const events = await EventService.findInZone( + Number(lat), + Number(lng), + Number(radius) + ); + + // Filter by startDate and status if provided + const filteredEvents = events + .filter(({ event }) => { + if (startDate && event.startDate < new Date(startDate)) return false; + if (status && event.status !== status) return false; + return true; + }); + + const geoJsonResponse: ApiResponse = { + status: "success", + data: { + type: "FeatureCollection", + features: filteredEvents.map(({ event, distance }) => ({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [ + (event.venue!.coordinates as { lng: number; lat: number }).lng, + (event.venue!.coordinates as { lng: number; lat: number }).lat + ] + }, + properties: { + id: event.id, + title: event.title, + description: event.description, + startDate: event.startDate.toISOString(), + endDate: event.endDate.toISOString(), + status: event.status as EventResponse["status"], + distance: Math.round(distance * 100) / 100, // Round to 2 decimal places + venue: { + id: event.venue!.id, + name: event.venue!.name, + address: event.venue!.address + }, + createdAt: event.createdAt.toISOString(), + updatedAt: event.updatedAt.toISOString() + } + })), + center: { + type: "Point", + coordinates: [Number(lng), Number(lat)] + }, + radius: Number(radius) + }, + timestamp: new Date().toISOString() + }; + + res.json(geoJsonResponse); + } catch (error) { + const response: ApiResponse = { + status: "error", + message: error instanceof Error ? error.message : "Failed to fetch events in zone", + timestamp: new Date().toISOString() + }; + + res.status(500).json(response); + } + } } diff --git a/server/src/routes/event.routes.ts b/server/src/routes/event.routes.ts index 23b3e50..f9b12ba 100644 --- a/server/src/routes/event.routes.ts +++ b/server/src/routes/event.routes.ts @@ -5,7 +5,8 @@ import { EventParamsSchema, EventQuerySchema, EventSchema, - EventUpdateSchema + EventUpdateSchema, + EventZoneQuerySchema } from "../types/event.types"; const router = Router(); @@ -13,6 +14,15 @@ const router = Router(); // List events (with query validation) router.get("/", validateRequest.query(EventQuerySchema), EventController.list); +// Get events in zone (with query validation) +router.get("/zone", validateRequest.query(EventZoneQuerySchema), EventController.findInZone); + +// Get event in GeoJSON format +router.get("/:id/geojson", + validateRequest.params(EventParamsSchema), + EventController.getGeoJson +); + // Get event by ID router.get("/:id", validateRequest.params(EventParamsSchema), EventController.getById); @@ -39,10 +49,4 @@ router.delete("/:id", EventController.delete ); -// Get event in GeoJSON format -router.get("/:id/geojson", - validateRequest.params(EventParamsSchema), - EventController.getGeoJson -); - export default router; \ No newline at end of file diff --git a/server/src/services/event.service.ts b/server/src/services/event.service.ts index 8cb510f..acb9a3a 100644 --- a/server/src/services/event.service.ts +++ b/server/src/services/event.service.ts @@ -2,6 +2,23 @@ import prisma from "../config/database"; import { EventInput, EventUpdateInput, EventQuery } from "../types/event.types"; import { Prisma } from "@prisma/client"; +// Utility function for Haversine formula +function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371; // Earth's radius in kilometers + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat/2) * Math.sin(dLat/2) + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * + Math.sin(dLon/2) * Math.sin(dLon/2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + return R * c; +} + +function toRad(degrees: number): number { + return degrees * (Math.PI/180); +} + export class EventService { static async findById(id: number) { const event = await prisma.event.findUnique({ @@ -159,4 +176,30 @@ export class EventService { throw error; } } + + static async findInZone(lat: number, lng: number, radius: number) { + // Get all events with their venues + const events = await prisma.event.findMany({ + include: { + venue: true + } + }); + + // Filter events by distance and map to GeoJSON + const eventsInZone = events + .filter(event => { + if (!event.venue?.coordinates) return false; + const venueCoords = event.venue.coordinates as { lat: number; lng: number }; + const distance = calculateDistance(lat, lng, venueCoords.lat, venueCoords.lng); + return distance <= radius; + }) + .map(event => { + const venueCoords = event.venue!.coordinates as { lat: number; lng: number }; + const distance = calculateDistance(lat, lng, venueCoords.lat, venueCoords.lng); + return { event, distance }; + }) + .sort((a, b) => a.distance - b.distance); + + return eventsInZone; + } } \ No newline at end of file diff --git a/server/src/types/event.types.ts b/server/src/types/event.types.ts index eaf21a3..51b5068 100644 --- a/server/src/types/event.types.ts +++ b/server/src/types/event.types.ts @@ -1,21 +1,22 @@ -import { z } from 'zod'; +import { z } from "zod"; // Enum for event status export const EventStatus = { - DRAFT: 'draft', - PUBLISHED: 'published', - CANCELLED: 'cancelled' + DRAFT: "draft", + PUBLISHED: "published", + CANCELLED: "cancelled", } as const; // Schema for validating event creation/updates export const EventSchema = z.object({ title: z.string().min(1, "Title is required").max(100), description: z.string().min(1, "Description is required").max(1000), - startDate: z.string().datetime(), // ISO 8601 format - endDate: z.string().datetime(), // ISO 8601 format - status: z.enum([EventStatus.DRAFT, EventStatus.PUBLISHED, EventStatus.CANCELLED]) + startDate: z.string().datetime(), // ISO 8601 format + endDate: z.string().datetime(), // ISO 8601 format + status: z + .enum([EventStatus.DRAFT, EventStatus.PUBLISHED, EventStatus.CANCELLED]) .default(EventStatus.DRAFT), - venueId: z.number().positive("Venue ID is required") + venueId: z.number().positive("Venue ID is required"), }); // Schema for PATCH operations - all fields are optional @@ -25,16 +26,40 @@ export const EventUpdateSchema = EventSchema.partial(); export const EventQuerySchema = z.object({ page: z.coerce.number().positive().optional(), limit: z.coerce.number().min(1).max(100).optional(), - status: z.enum([EventStatus.DRAFT, EventStatus.PUBLISHED, EventStatus.CANCELLED]).optional(), - orderBy: z.enum(['startDate', 'title', 'createdAt']).optional(), - order: z.enum(['asc', 'desc']).optional() + status: z + .enum([EventStatus.DRAFT, EventStatus.PUBLISHED, EventStatus.CANCELLED]) + .optional(), + orderBy: z.enum(["startDate", "title", "createdAt"]).optional(), + order: z.enum(["asc", "desc"]).optional(), }); // Schema for URL parameters export const EventParamsSchema = z.object({ - id: z.string() + id: z + .string() .regex(/^\d+$/, "ID must be a positive integer") - .refine((val) => parseInt(val) > 0, "ID must be positive") + .refine((val) => parseInt(val) > 0, "ID must be positive"), +}); + +// Schema for zone search query parameters +export const EventZoneQuerySchema = z.object({ + lat: z.coerce + .number() + .min(-90, "Latitude must be between -90 and 90") + .max(90, "Latitude must be between -90 and 90"), + lng: z.coerce + .number() + .min(-180, "Longitude must be between -180 and 180") + .max(180, "Longitude must be between -180 and 180"), + radius: z.coerce + .number() + .positive("Radius must be positive") + .max(50, "Radius cannot exceed 50 kilometers"), + // Optional filters + startDate: z.string().datetime().optional(), // Filter events starting after this time + status: z + .enum([EventStatus.DRAFT, EventStatus.PUBLISHED, EventStatus.CANCELLED]) + .optional(), }); // TypeScript types derived from Zod schemas @@ -42,6 +67,7 @@ export type EventInput = z.infer; export type EventUpdateInput = z.infer; export type EventQuery = z.infer; export type EventParams = z.infer; +export type EventZoneQuery = z.infer; // Type for API responses export type EventResponse = { @@ -65,11 +91,22 @@ export type EventResponse = { updatedAt: string; }; +// Type for zone search response +export type EventZoneResponse = { + type: "FeatureCollection"; + features: EventGeoJSONFeature[]; + center: { + type: "Point"; + coordinates: [number, number]; // [longitude, latitude] + }; + radius: number; // Search radius in kilometers +}; + // GeoJSON types for events export type EventGeoJSONFeature = { - type: 'Feature'; + type: "Feature"; geometry: { - type: 'Point'; + type: "Point"; coordinates: [number, number]; // [longitude, latitude] from venue }; properties: { @@ -87,4 +124,13 @@ export type EventGeoJSONFeature = { createdAt: string; updatedAt: string; }; -}; \ No newline at end of file +}; + +// Type for coerced zone query parameters +export type EventZoneQueryCoerced = { + lat: string; + lng: string; + radius: string; + startDate?: string; + status?: keyof typeof EventStatus; +};