diff --git a/apps/citycorners/apps/backend/src/__tests__/mock-factories.ts b/apps/citycorners/apps/backend/src/__tests__/mock-factories.ts index 342e69c7e..52d5b6e2d 100644 --- a/apps/citycorners/apps/backend/src/__tests__/mock-factories.ts +++ b/apps/citycorners/apps/backend/src/__tests__/mock-factories.ts @@ -15,6 +15,7 @@ export function createMockLocation(overrides: Partial = {}): Location longitude: 9.1757, imageUrl: '/images/muenster.svg', timeline: [{ year: '615', event: 'Founded' }], + createdBy: null, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'), ...overrides, @@ -38,6 +39,7 @@ export function createMockDb() { where: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), insert: jest.fn().mockReturnThis(), values: jest.fn().mockReturnThis(), returning: jest.fn(), diff --git a/apps/citycorners/apps/backend/src/db/schema/locations.schema.ts b/apps/citycorners/apps/backend/src/db/schema/locations.schema.ts index c368563a7..9c0ecdde1 100644 --- a/apps/citycorners/apps/backend/src/db/schema/locations.schema.ts +++ b/apps/citycorners/apps/backend/src/db/schema/locations.schema.ts @@ -20,6 +20,7 @@ export const locations = pgTable('locations', { longitude: doublePrecision('longitude'), imageUrl: text('image_url'), timeline: jsonb('timeline').$type().default([]), + createdBy: text('created_by'), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }) .defaultNow() diff --git a/apps/citycorners/apps/backend/src/location/location.controller.spec.ts b/apps/citycorners/apps/backend/src/location/location.controller.spec.ts index 31786ae02..2756cd01f 100644 --- a/apps/citycorners/apps/backend/src/location/location.controller.spec.ts +++ b/apps/citycorners/apps/backend/src/location/location.controller.spec.ts @@ -26,23 +26,34 @@ describe('LocationController', () => { afterEach(() => jest.clearAllMocks()); describe('findAll', () => { - it('should return all locations', async () => { + it('should return paginated locations', async () => { const locations = [createMockLocation(), createMockLocation({ id: 'loc-2' })]; - locationService.findAll.mockResolvedValue(locations); + locationService.findAll.mockResolvedValue({ + items: locations, + total: 2, + page: 1, + limit: 20, + totalPages: 1, + }); const result = await controller.findAll(); - expect(result).toEqual({ locations }); + expect(result.locations).toEqual(locations); + expect(result.pagination).toEqual({ total: 2, page: 1, limit: 20, totalPages: 1 }); }); - it('should filter by category', async () => { - const museums = [createMockLocation({ category: 'museum' })]; - locationService.findAll.mockResolvedValue(museums); + it('should pass category and pagination params', async () => { + locationService.findAll.mockResolvedValue({ + items: [], + total: 0, + page: 2, + limit: 10, + totalPages: 0, + }); - const result = await controller.findAll('museum'); + await controller.findAll('museum', '2', '10'); - expect(result).toEqual({ locations: museums }); - expect(locationService.findAll).toHaveBeenCalledWith('museum'); + expect(locationService.findAll).toHaveBeenCalledWith('museum', 2, 10); }); }); @@ -86,8 +97,8 @@ describe('LocationController', () => { }); describe('create', () => { - it('should create a location', async () => { - const location = createMockLocation({ id: 'new-loc' }); + it('should create a location with createdBy', async () => { + const location = createMockLocation({ id: 'new-loc', createdBy: TEST_USER_ID }); locationService.create.mockResolvedValue(location); const result = await controller.create(mockUser, { @@ -97,16 +108,38 @@ describe('LocationController', () => { }); expect(result).toEqual({ location }); + expect(locationService.create).toHaveBeenCalledWith({ + name: 'Test', + category: 'sight', + description: 'A test location', + createdBy: TEST_USER_ID, + }); + }); + }); + + describe('update', () => { + it('should pass userId to service', async () => { + const location = createMockLocation({ name: 'Updated' }); + locationService.update.mockResolvedValue(location); + + await controller.update(mockUser, 'loc-1', { name: 'Updated' }); + + expect(locationService.update).toHaveBeenCalledWith( + 'loc-1', + { name: 'Updated' }, + TEST_USER_ID + ); }); }); describe('delete', () => { - it('should delete a location', async () => { + it('should pass userId to service', async () => { locationService.delete.mockResolvedValue(undefined); const result = await controller.delete(mockUser, 'loc-1'); expect(result).toEqual({ success: true }); + expect(locationService.delete).toHaveBeenCalledWith('loc-1', TEST_USER_ID); }); }); }); diff --git a/apps/citycorners/apps/backend/src/location/location.controller.ts b/apps/citycorners/apps/backend/src/location/location.controller.ts index 6d197b4bb..e373c4a1f 100644 --- a/apps/citycorners/apps/backend/src/location/location.controller.ts +++ b/apps/citycorners/apps/backend/src/location/location.controller.ts @@ -77,9 +77,24 @@ export class LocationController { ) {} @Get() - async findAll(@Query('category') category?: string) { - const locations = await this.locationService.findAll(category); - return { locations }; + async findAll( + @Query('category') category?: string, + @Query('page') page?: string, + @Query('limit') limit?: string + ) { + const pageNum = page ? Math.max(1, parseInt(page, 10)) : 1; + const limitNum = limit ? Math.min(100, Math.max(1, parseInt(limit, 10))) : 20; + + const result = await this.locationService.findAll(category, pageNum, limitNum); + return { + locations: result.items, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }; } @Get('lookup') @@ -109,7 +124,10 @@ export class LocationController { @Post() @UseGuards(JwtAuthGuard) async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateLocationDto) { - const location = await this.locationService.create(dto); + const location = await this.locationService.create({ + ...dto, + createdBy: user.userId, + }); return { location }; } @@ -120,14 +138,14 @@ export class LocationController { @Param('id') id: string, @Body() dto: UpdateLocationDto ) { - const location = await this.locationService.update(id, dto); + const location = await this.locationService.update(id, dto, user.userId); return { location }; } @Delete(':id') @UseGuards(JwtAuthGuard) async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - await this.locationService.delete(id); + await this.locationService.delete(id, user.userId); return { success: true }; } } diff --git a/apps/citycorners/apps/backend/src/location/location.service.spec.ts b/apps/citycorners/apps/backend/src/location/location.service.spec.ts index 87a4dd3bb..f49226524 100644 --- a/apps/citycorners/apps/backend/src/location/location.service.spec.ts +++ b/apps/citycorners/apps/backend/src/location/location.service.spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; +import { NotFoundException, ForbiddenException } from '@nestjs/common'; import { LocationService } from './location.service'; import { DATABASE_CONNECTION } from '../db/database.module'; import { createMockDb, createMockLocation } from '../__tests__/mock-factories'; @@ -21,19 +21,23 @@ describe('LocationService', () => { afterEach(() => jest.clearAllMocks()); describe('findAll', () => { - it('should return all locations', async () => { + it('should return paginated locations', async () => { const locations = [ createMockLocation(), createMockLocation({ id: 'loc-2', name: 'Imperia' }), ]; - mockDb.where.mockResolvedValue(locations); - // findAll without category calls db.select().from(locations) which resolves via the chain - // Need to handle the case without category - mockDb.from.mockResolvedValue(locations); + // Without category: count calls from() which resolves, data calls offset() + mockDb.from + .mockResolvedValueOnce([{ count: 2 }]) // count query + .mockReturnThis(); // data query chain continues + mockDb.offset.mockResolvedValue(locations); const result = await service.findAll(); - expect(result).toEqual(locations); + expect(result.items).toEqual(locations); + expect(result.total).toBe(2); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); expect(mockDb.select).toHaveBeenCalled(); }); @@ -41,11 +45,25 @@ describe('LocationService', () => { const museums = [ createMockLocation({ id: 'loc-3', category: 'museum', name: 'Rosgartenmuseum' }), ]; - mockDb.where.mockResolvedValue(museums); + // With category: count calls where(), data calls offset() + mockDb.where.mockResolvedValueOnce([{ count: 1 }]); // count query + mockDb.offset.mockResolvedValue(museums); const result = await service.findAll('museum'); - expect(result).toEqual(museums); + expect(result.items).toEqual(museums); + expect(result.total).toBe(1); + }); + + it('should respect page and limit', async () => { + mockDb.from.mockResolvedValueOnce([{ count: 50 }]).mockReturnThis(); + mockDb.offset.mockResolvedValue([]); + + const result = await service.findAll(undefined, 3, 10); + + expect(result.page).toBe(3); + expect(result.limit).toBe(10); + expect(result.totalPages).toBe(5); }); }); @@ -102,17 +120,39 @@ describe('LocationService', () => { }); describe('update', () => { - it('should update a location', async () => { - const updated = createMockLocation({ name: 'Updated Name' }); + it('should update a location owned by user', async () => { + const existing = createMockLocation({ createdBy: 'user-1' }); + mockDb.where.mockResolvedValueOnce([existing]); // findById + const updated = createMockLocation({ name: 'Updated Name', createdBy: 'user-1' }); mockDb.returning.mockResolvedValue([updated]); - const result = await service.update('loc-1', { name: 'Updated Name' }); + const result = await service.update('loc-1', { name: 'Updated Name' }, 'user-1'); expect(result.name).toBe('Updated Name'); }); + it('should throw ForbiddenException if not owner', async () => { + const existing = createMockLocation({ createdBy: 'other-user' }); + mockDb.where.mockResolvedValueOnce([existing]); // findById + + await expect(service.update('loc-1', { name: 'Hacked' }, 'attacker-user')).rejects.toThrow( + ForbiddenException + ); + }); + + it('should allow update of unowned locations', async () => { + const existing = createMockLocation({ createdBy: null as any }); + mockDb.where.mockResolvedValueOnce([existing]); // findById + const updated = createMockLocation({ name: 'Updated' }); + mockDb.returning.mockResolvedValue([updated]); + + const result = await service.update('loc-1', { name: 'Updated' }, 'any-user'); + + expect(result.name).toBe('Updated'); + }); + it('should throw NotFoundException if not found', async () => { - mockDb.returning.mockResolvedValue([]); + mockDb.where.mockResolvedValue([]); await expect(service.update('nonexistent', { name: 'Test' })).rejects.toThrow( NotFoundException @@ -121,14 +161,22 @@ describe('LocationService', () => { }); describe('delete', () => { - it('should delete a location', async () => { - mockDb.returning.mockResolvedValue([createMockLocation()]); + it('should delete a location owned by user', async () => { + const existing = createMockLocation({ createdBy: 'user-1' }); + mockDb.where.mockResolvedValueOnce([existing]); // findById - await expect(service.delete('loc-1')).resolves.not.toThrow(); + await expect(service.delete('loc-1', 'user-1')).resolves.not.toThrow(); + }); + + it('should throw ForbiddenException if not owner', async () => { + const existing = createMockLocation({ createdBy: 'other-user' }); + mockDb.where.mockResolvedValueOnce([existing]); // findById + + await expect(service.delete('loc-1', 'attacker-user')).rejects.toThrow(ForbiddenException); }); it('should throw NotFoundException if not found', async () => { - mockDb.returning.mockResolvedValue([]); + mockDb.where.mockResolvedValue([]); await expect(service.delete('nonexistent')).rejects.toThrow(NotFoundException); }); diff --git a/apps/citycorners/apps/backend/src/location/location.service.ts b/apps/citycorners/apps/backend/src/location/location.service.ts index 47050fb17..dbdf8d5c7 100644 --- a/apps/citycorners/apps/backend/src/location/location.service.ts +++ b/apps/citycorners/apps/backend/src/location/location.service.ts @@ -1,22 +1,63 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, or, ilike } from 'drizzle-orm'; +import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { eq, or, ilike, sql, desc } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../db/database.module'; import { Database } from '../db/connection'; import { locations } from '../db/schema'; import type { Location, NewLocation } from '../db/schema'; +export interface PaginatedResult { + items: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + @Injectable() export class LocationService { constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - async findAll(category?: string): Promise { + async findAll(category?: string, page = 1, limit = 20): Promise> { + const offset = (page - 1) * limit; + + let items: Location[]; + let total: number; + if (category) { - return this.db - .select() + const countResult = await this.db + .select({ count: sql`count(*)::int` }) .from(locations) .where(eq(locations.category, category as Location['category'])); + total = countResult[0]?.count ?? 0; + + items = await this.db + .select() + .from(locations) + .where(eq(locations.category, category as Location['category'])) + .orderBy(desc(locations.createdAt)) + .limit(limit) + .offset(offset); + } else { + const countResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(locations); + total = countResult[0]?.count ?? 0; + + items = await this.db + .select() + .from(locations) + .orderBy(desc(locations.createdAt)) + .limit(limit) + .offset(offset); } - return this.db.select().from(locations); + + return { + items, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; } async search(query: string): Promise { @@ -46,22 +87,30 @@ export class LocationService { return location; } - async update(id: string, data: Partial): Promise { + async update(id: string, data: Partial, userId?: string): Promise { + const existing = await this.findById(id); + + // If location has an owner, only the owner can edit + if (existing.createdBy && userId && existing.createdBy !== userId) { + throw new ForbiddenException('You can only edit your own locations'); + } + const [location] = await this.db .update(locations) .set(data) .where(eq(locations.id, id)) .returning(); - if (!location) { - throw new NotFoundException(`Location with id ${id} not found`); - } return location; } - async delete(id: string): Promise { - const [location] = await this.db.delete(locations).where(eq(locations.id, id)).returning(); - if (!location) { - throw new NotFoundException(`Location with id ${id} not found`); + async delete(id: string, userId?: string): Promise { + const existing = await this.findById(id); + + // If location has an owner, only the owner can delete + if (existing.createdBy && userId && existing.createdBy !== userId) { + throw new ForbiddenException('You can only delete your own locations'); } + + await this.db.delete(locations).where(eq(locations.id, id)); } } diff --git a/apps/citycorners/apps/web/src/lib/i18n/locales/de.json b/apps/citycorners/apps/web/src/lib/i18n/locales/de.json index 7d093915f..cfc93e18a 100644 --- a/apps/citycorners/apps/web/src/lib/i18n/locales/de.json +++ b/apps/citycorners/apps/web/src/lib/i18n/locales/de.json @@ -19,7 +19,8 @@ "loading": "Laden...", "noResults": "Keine Locations gefunden.", "noResultsCategory": "Keine {category} gefunden.", - "addFirst": "Ersten Ort hinzufügen" + "addFirst": "Ersten Ort hinzufügen", + "loadMore": "Mehr laden" }, "categories": { "sight": "Sehenswürdigkeiten", @@ -42,7 +43,13 @@ "linkCopied": "Link kopiert!", "showDetails": "Details", "back": "Zurück zur Übersicht", - "notFound": "Location nicht gefunden." + "notFound": "Location nicht gefunden.", + "edit": "Bearbeiten", + "delete": "Löschen", + "deleteConfirm": "Bist du sicher, dass du diesen Ort löschen möchtest? Das kann nicht rückgängig gemacht werden.", + "confirmDelete": "Endgültig löschen", + "deleting": "Wird gelöscht...", + "cancel": "Abbrechen" }, "favorites": { "title": "Favoriten", @@ -116,6 +123,16 @@ "geocoding": "Koordinaten werden ermittelt...", "coordinatesFound": "Koordinaten gefunden" }, + "edit": { + "title": "Ort bearbeiten", + "subtitle": "Ändere die Details dieses Ortes", + "save": "Änderungen speichern", + "saving": "Wird gespeichert...", + "cancel": "Abbrechen", + "error": "Fehler beim Speichern. Bitte versuche es erneut.", + "loadError": "Ort konnte nicht geladen werden.", + "forbidden": "Du kannst nur deine eigenen Orte bearbeiten." + }, "offline": { "title": "Keine Verbindung", "message": "Du bist gerade offline. Sobald du wieder eine Internetverbindung hast, kannst du CityCorners weiter nutzen.", diff --git a/apps/citycorners/apps/web/src/lib/i18n/locales/en.json b/apps/citycorners/apps/web/src/lib/i18n/locales/en.json index 330920ad8..2e6a47bb3 100644 --- a/apps/citycorners/apps/web/src/lib/i18n/locales/en.json +++ b/apps/citycorners/apps/web/src/lib/i18n/locales/en.json @@ -19,7 +19,8 @@ "loading": "Loading...", "noResults": "No locations found.", "noResultsCategory": "No {category} found.", - "addFirst": "Add the first place" + "addFirst": "Add the first place", + "loadMore": "Load more" }, "categories": { "sight": "Sights", @@ -42,7 +43,13 @@ "linkCopied": "Link copied!", "showDetails": "Details", "back": "Back to overview", - "notFound": "Location not found." + "notFound": "Location not found.", + "edit": "Edit", + "delete": "Delete", + "deleteConfirm": "Are you sure you want to delete this place? This cannot be undone.", + "confirmDelete": "Delete permanently", + "deleting": "Deleting...", + "cancel": "Cancel" }, "favorites": { "title": "Favorites", @@ -116,6 +123,16 @@ "geocoding": "Finding coordinates...", "coordinatesFound": "Coordinates found" }, + "edit": { + "title": "Edit place", + "subtitle": "Update the details of this place", + "save": "Save changes", + "saving": "Saving...", + "cancel": "Cancel", + "error": "Failed to save. Please try again.", + "loadError": "Could not load place.", + "forbidden": "You can only edit your own places." + }, "offline": { "title": "No connection", "message": "You are currently offline. You can continue using CityCorners once you have an internet connection again.", diff --git a/apps/citycorners/apps/web/src/routes/(app)/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/+page.svelte index d1b1f2393..30b1dcff0 100644 --- a/apps/citycorners/apps/web/src/routes/(app)/+page.svelte +++ b/apps/citycorners/apps/web/src/routes/(app)/+page.svelte @@ -14,10 +14,20 @@ latitude?: number; longitude?: number; imageUrl?: string; + createdBy?: string; + } + + interface Pagination { + total: number; + page: number; + limit: number; + totalPages: number; } let locations = $state([]); + let pagination = $state(null); let loading = $state(true); + let loadingMore = $state(false); let selectedCategory = $state(null); const categoryKeys = ['sight', 'restaurant', 'shop', 'museum']; @@ -36,22 +46,54 @@ selectedCategory ? locations.filter((l) => l.category === selectedCategory) : locations ); - onMount(async () => { + let hasMore = $derived(pagination ? pagination.page < pagination.totalPages : false); + + async function loadLocations(page = 1, append = false) { + if (page === 1) loading = true; + else loadingMore = true; + try { - const res = await fetch(api('/locations')); + const params = new URLSearchParams({ page: String(page), limit: '20' }); + if (selectedCategory) params.set('category', selectedCategory); + const res = await fetch(api(`/locations?${params}`)); const data = await res.json(); - locations = data.locations; + if (append) { + locations = [...locations, ...data.locations]; + } else { + locations = data.locations; + } + pagination = data.pagination; } catch (err) { console.error('Failed to load locations:', err); } finally { loading = false; + loadingMore = false; } + } + function loadMore() { + if (pagination && hasMore && !loadingMore) { + loadLocations(pagination.page + 1, true); + } + } + + onMount(() => { + loadLocations(); if (authStore.isAuthenticated) { favoritesStore.load(); } }); + // Reload when category changes + $effect(() => { + // Track selectedCategory to re-run + const _ = selectedCategory; + // Don't run on initial mount (loading is still true) + if (!loading || locations.length > 0) { + loadLocations(1); + } + }); + function handleFavoriteToggle(e: MouseEvent, locationId: string) { e.preventDefault(); e.stopPropagation(); @@ -86,7 +128,8 @@ : 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}" onclick={() => (selectedCategory = null)} > - {$_('home.all')} ({locations.length}) + {$_('home.all')} + {pagination ? `(${pagination.total})` : ''} {#each categoryKeys as cat} + + {/if} {/if} diff --git a/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/+page.svelte index c26a30240..273ebfb20 100644 --- a/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/+page.svelte +++ b/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/+page.svelte @@ -1,5 +1,6 @@ @@ -253,6 +284,74 @@

{location.description}

+ + {#if isOwner} +
+ + + + + {$_('detail.edit')} + + +
+ {/if} + + + {#if showDeleteConfirm} +
+

{$_('detail.deleteConfirm')}

+
+ + +
+
+ {/if} + {#if location.latitude && location.longitude}
diff --git a/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/edit/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/edit/+page.svelte new file mode 100644 index 000000000..13fe78be1 --- /dev/null +++ b/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/edit/+page.svelte @@ -0,0 +1,244 @@ + + + + {$_('edit.title')} - CityCorners + + +
+

{$_('edit.title')}

+

{$_('edit.subtitle')}

+
+ +{#if loading} +
+
+
+{:else if forbidden} +
+ 🔒 +

{$_('edit.forbidden')}

+ + {$_('detail.back')} + +
+{:else} +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-5" + > + {#if error} +
{error}
+ {/if} + +
+ + +
+ +
+ +
+ {#each categories as cat} + + {/each} +
+
+ +
+ + +

{$_('add.minChars')}

+
+ +
+ + +
+ +
+ + (imageError = false)} + placeholder={$_('add.imageUrlPlaceholder')} + class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + /> + {#if imageUrl.trim() && !imageError} +
+ {$_('add.imagePreview')} (imageError = true)} + /> +
+ {:else if imageError} +
+

{$_('add.imageLoadError')}

+ +
+ {/if} +
+ +
+ + {$_('edit.cancel')} + + +
+
+{/if}