From 73037097102b8b1f0ceb0d9c94ad13f6910c8d73 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 25 Mar 2026 20:28:23 +0100 Subject: [PATCH] feat(citycorners): add review and rating system - New reviews table (userId, locationId, rating 1-5, comment, unique per user) - ReviewService with CRUD, stats aggregation, batch stats for lists - ReviewController with GET/POST/DELETE endpoints at /reviews/:locationId - Location list and detail endpoints now include reviewStats (avg + count) - Star rating display on location cards (home page) - Full review section on detail page: star picker, comment, submit, delete - i18n: 13 new review-related translations (DE/EN) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/backend/src/app.module.ts | 2 + .../apps/backend/src/db/schema/index.ts | 1 + .../backend/src/db/schema/reviews.schema.ts | 22 ++ .../src/location/location.controller.ts | 15 +- .../backend/src/location/location.module.ts | 2 + .../backend/src/review/review.controller.ts | 60 +++++ .../apps/backend/src/review/review.module.ts | 10 + .../apps/backend/src/review/review.service.ts | 91 +++++++ .../apps/web/src/lib/i18n/locales/de.json | 16 ++ .../apps/web/src/lib/i18n/locales/en.json | 16 ++ .../apps/web/src/routes/(app)/+page.svelte | 23 ++ .../routes/(app)/locations/[id]/+page.svelte | 231 ++++++++++++++++++ 12 files changed, 486 insertions(+), 3 deletions(-) create mode 100644 apps/citycorners/apps/backend/src/db/schema/reviews.schema.ts create mode 100644 apps/citycorners/apps/backend/src/review/review.controller.ts create mode 100644 apps/citycorners/apps/backend/src/review/review.module.ts create mode 100644 apps/citycorners/apps/backend/src/review/review.service.ts diff --git a/apps/citycorners/apps/backend/src/app.module.ts b/apps/citycorners/apps/backend/src/app.module.ts index 6ae202f9d..bdb6d169d 100644 --- a/apps/citycorners/apps/backend/src/app.module.ts +++ b/apps/citycorners/apps/backend/src/app.module.ts @@ -4,6 +4,7 @@ import { DatabaseModule } from './db/database.module'; import { LocationModule } from './location/location.module'; import { FavoriteModule } from './favorite/favorite.module'; import { CollectionModule } from './collection/collection.module'; +import { ReviewModule } from './review/review.module'; import { HealthModule } from '@manacore/shared-nestjs-health'; import { MetricsModule } from '@manacore/shared-nestjs-metrics'; @@ -17,6 +18,7 @@ import { MetricsModule } from '@manacore/shared-nestjs-metrics'; LocationModule, FavoriteModule, CollectionModule, + ReviewModule, HealthModule.forRoot({ serviceName: 'citycorners-backend' }), MetricsModule.register({ prefix: 'citycorners_', diff --git a/apps/citycorners/apps/backend/src/db/schema/index.ts b/apps/citycorners/apps/backend/src/db/schema/index.ts index 01100f11d..ef5930e90 100644 --- a/apps/citycorners/apps/backend/src/db/schema/index.ts +++ b/apps/citycorners/apps/backend/src/db/schema/index.ts @@ -1,3 +1,4 @@ export * from './locations.schema'; export * from './favorites.schema'; export * from './collections.schema'; +export * from './reviews.schema'; diff --git a/apps/citycorners/apps/backend/src/db/schema/reviews.schema.ts b/apps/citycorners/apps/backend/src/db/schema/reviews.schema.ts new file mode 100644 index 000000000..0c064cf61 --- /dev/null +++ b/apps/citycorners/apps/backend/src/db/schema/reviews.schema.ts @@ -0,0 +1,22 @@ +import { pgTable, uuid, text, integer, timestamp, unique } from 'drizzle-orm/pg-core'; +import { locations } from './locations.schema'; + +export const reviews = pgTable( + 'reviews', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + locationId: uuid('location_id') + .notNull() + .references(() => locations.id, { onDelete: 'cascade' }), + rating: integer('rating').notNull(), + comment: text('comment'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + uniqueUserLocation: unique().on(table.userId, table.locationId), + }) +); + +export type Review = typeof reviews.$inferSelect; +export type NewReview = typeof reviews.$inferInsert; diff --git a/apps/citycorners/apps/backend/src/location/location.controller.ts b/apps/citycorners/apps/backend/src/location/location.controller.ts index 946afcfff..086b62e2c 100644 --- a/apps/citycorners/apps/backend/src/location/location.controller.ts +++ b/apps/citycorners/apps/backend/src/location/location.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } fro import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { LocationService } from './location.service'; import { LocationLookupService } from './location-lookup.service'; +import { ReviewService } from '../review/review.service'; import { RateLimitGuard } from '../guards/rate-limit.guard'; import { IsString, IsNotEmpty, IsOptional, IsNumber, IsObject } from 'class-validator'; import { Type } from 'class-transformer'; @@ -121,7 +122,8 @@ class UpdateLocationDto { export class LocationController { constructor( private readonly locationService: LocationService, - private readonly lookupService: LocationLookupService + private readonly lookupService: LocationLookupService, + private readonly reviewService: ReviewService ) {} @Get() @@ -134,8 +136,14 @@ export class LocationController { const limitNum = limit ? Math.min(100, Math.max(1, parseInt(limit, 10))) : 20; const result = await this.locationService.findAll(category, pageNum, limitNum); + const locationIds = result.items.map((l) => l.id); + const reviewStats = await this.reviewService.getStatsForLocations(locationIds); + return { - locations: result.items, + locations: result.items.map((l) => ({ + ...l, + reviewStats: reviewStats[l.id] || { averageRating: 0, totalReviews: 0 }, + })), pagination: { total: result.total, page: result.page, @@ -175,7 +183,8 @@ export class LocationController { @Get(':id') async findById(@Param('id') id: string) { const location = await this.locationService.findByIdOrSlug(id); - return { location }; + const reviewStats = await this.reviewService.getStats(location.id); + return { location: { ...location, reviewStats } }; } @Get(':id/nearby') diff --git a/apps/citycorners/apps/backend/src/location/location.module.ts b/apps/citycorners/apps/backend/src/location/location.module.ts index 08c065c8f..3b68e7bc6 100644 --- a/apps/citycorners/apps/backend/src/location/location.module.ts +++ b/apps/citycorners/apps/backend/src/location/location.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { LocationController } from './location.controller'; import { LocationService } from './location.service'; import { LocationLookupService } from './location-lookup.service'; +import { ReviewModule } from '../review/review.module'; @Module({ + imports: [ReviewModule], controllers: [LocationController], providers: [LocationService, LocationLookupService], exports: [LocationService], diff --git a/apps/citycorners/apps/backend/src/review/review.controller.ts b/apps/citycorners/apps/backend/src/review/review.controller.ts new file mode 100644 index 000000000..242540bed --- /dev/null +++ b/apps/citycorners/apps/backend/src/review/review.controller.ts @@ -0,0 +1,60 @@ +import { Controller, Get, Post, Delete, Param, Body, UseGuards, Query } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { ReviewService } from './review.service'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +class CreateReviewDto { + @IsInt() + @Min(1) + @Max(5) + @Type(() => Number) + rating!: number; + + @IsString() + @IsOptional() + comment?: string; +} + +@Controller('reviews') +export class ReviewController { + constructor(private readonly reviewService: ReviewService) {} + + @Get(':locationId') + async findByLocation(@Param('locationId') locationId: string) { + const [reviewsList, stats] = await Promise.all([ + this.reviewService.findByLocationId(locationId), + this.reviewService.getStats(locationId), + ]); + return { reviews: reviewsList, stats }; + } + + @Get(':locationId/stats') + async getStats(@Param('locationId') locationId: string) { + const stats = await this.reviewService.getStats(locationId); + return { stats }; + } + + @Post(':locationId') + @UseGuards(JwtAuthGuard) + async create( + @CurrentUser() user: CurrentUserData, + @Param('locationId') locationId: string, + @Body() dto: CreateReviewDto + ) { + const review = await this.reviewService.create( + user.userId, + locationId, + dto.rating, + dto.comment + ); + return { review }; + } + + @Delete(':locationId') + @UseGuards(JwtAuthGuard) + async remove(@CurrentUser() user: CurrentUserData, @Param('locationId') locationId: string) { + await this.reviewService.delete(user.userId, locationId); + return { success: true }; + } +} diff --git a/apps/citycorners/apps/backend/src/review/review.module.ts b/apps/citycorners/apps/backend/src/review/review.module.ts new file mode 100644 index 000000000..2f8f6fa15 --- /dev/null +++ b/apps/citycorners/apps/backend/src/review/review.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ReviewController } from './review.controller'; +import { ReviewService } from './review.service'; + +@Module({ + controllers: [ReviewController], + providers: [ReviewService], + exports: [ReviewService], +}) +export class ReviewModule {} diff --git a/apps/citycorners/apps/backend/src/review/review.service.ts b/apps/citycorners/apps/backend/src/review/review.service.ts new file mode 100644 index 000000000..bf865e009 --- /dev/null +++ b/apps/citycorners/apps/backend/src/review/review.service.ts @@ -0,0 +1,91 @@ +import { Injectable, Inject, ConflictException } from '@nestjs/common'; +import { eq, and, sql, desc } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { reviews } from '../db/schema'; +import type { Review } from '../db/schema'; + +export interface ReviewStats { + averageRating: number; + totalReviews: number; +} + +@Injectable() +export class ReviewService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async findByLocationId(locationId: string): Promise { + return this.db + .select() + .from(reviews) + .where(eq(reviews.locationId, locationId)) + .orderBy(desc(reviews.createdAt)); + } + + async getStats(locationId: string): Promise { + const [result] = await this.db + .select({ + averageRating: sql`coalesce(round(avg(${reviews.rating})::numeric, 1), 0)::float`, + totalReviews: sql`count(*)::int`, + }) + .from(reviews) + .where(eq(reviews.locationId, locationId)); + + return result || { averageRating: 0, totalReviews: 0 }; + } + + async getStatsForLocations(locationIds: string[]): Promise> { + if (locationIds.length === 0) return {}; + + const results = await this.db + .select({ + locationId: reviews.locationId, + averageRating: sql`round(avg(${reviews.rating})::numeric, 1)::float`, + totalReviews: sql`count(*)::int`, + }) + .from(reviews) + .where( + sql`${reviews.locationId} = ANY(${sql.raw(`ARRAY[${locationIds.map((id) => `'${id}'::uuid`).join(',')}]`)})` + ) + .groupBy(reviews.locationId); + + const map: Record = {}; + for (const r of results) { + map[r.locationId] = { averageRating: r.averageRating, totalReviews: r.totalReviews }; + } + return map; + } + + async create( + userId: string, + locationId: string, + rating: number, + comment?: string + ): Promise { + const existing = await this.db + .select() + .from(reviews) + .where(and(eq(reviews.userId, userId), eq(reviews.locationId, locationId))); + + if (existing.length > 0) { + throw new ConflictException('You have already reviewed this location'); + } + + const [review] = await this.db + .insert(reviews) + .values({ + userId, + locationId, + rating: Math.min(5, Math.max(1, rating)), + comment: comment || null, + }) + .returning(); + return review; + } + + async delete(userId: string, locationId: string): Promise { + await this.db + .delete(reviews) + .where(and(eq(reviews.userId, userId), eq(reviews.locationId, locationId))); + } +} 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 39ab7d34a..b023b68f8 100644 --- a/apps/citycorners/apps/web/src/lib/i18n/locales/de.json +++ b/apps/citycorners/apps/web/src/lib/i18n/locales/de.json @@ -191,6 +191,22 @@ "loadError": "Ort konnte nicht geladen werden.", "forbidden": "Du kannst nur deine eigenen Orte bearbeiten." }, + "reviews": { + "title": "Bewertungen", + "write": "Bewertung schreiben", + "yourRating": "Deine Bewertung", + "commentPlaceholder": "Was hat dir gefallen? (optional)", + "submit": "Absenden", + "submitting": "Wird gesendet...", + "loginRequired": "Melde dich an, um eine Bewertung zu schreiben.", + "alreadyReviewed": "Du hast diesen Ort bereits bewertet.", + "deleteConfirm": "Bewertung löschen?", + "delete": "Löschen", + "noReviews": "Noch keine Bewertungen. Sei der Erste!", + "error": "Bewertung konnte nicht gespeichert werden.", + "count": "{count} Bewertungen", + "countOne": "1 Bewertung" + }, "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 b3675edef..24f5322d0 100644 --- a/apps/citycorners/apps/web/src/lib/i18n/locales/en.json +++ b/apps/citycorners/apps/web/src/lib/i18n/locales/en.json @@ -191,6 +191,22 @@ "loadError": "Could not load place.", "forbidden": "You can only edit your own places." }, + "reviews": { + "title": "Reviews", + "write": "Write a review", + "yourRating": "Your rating", + "commentPlaceholder": "What did you like? (optional)", + "submit": "Submit", + "submitting": "Submitting...", + "loginRequired": "Sign in to write a review.", + "alreadyReviewed": "You have already reviewed this place.", + "deleteConfirm": "Delete review?", + "delete": "Delete", + "noReviews": "No reviews yet. Be the first!", + "error": "Could not save review.", + "count": "{count} reviews", + "countOne": "1 review" + }, "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 38fccd3de..93adda58b 100644 --- a/apps/citycorners/apps/web/src/routes/(app)/+page.svelte +++ b/apps/citycorners/apps/web/src/routes/(app)/+page.svelte @@ -17,6 +17,7 @@ longitude?: number; imageUrl?: string; openingHours?: Record; + reviewStats?: { averageRating: number; totalReviews: number }; createdBy?: string; } @@ -282,6 +283,28 @@

{location.description}

+ {#if location.reviewStats && location.reviewStats.totalReviews > 0} +
+
+ {#each Array(5) as _, i} + + + + {/each} +
+ {location.reviewStats.averageRating.toFixed(1)} + ({location.reviewStats.totalReviews}) +
+ {/if} {/each} 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 d3a95e995..16e865d3b 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 @@ -29,6 +29,19 @@ distance: number; } + interface ReviewStats { + averageRating: number; + totalReviews: number; + } + + interface Review { + id: string; + userId: string; + rating: number; + comment?: string; + createdAt: string; + } + interface Location { id: string; slug?: string; @@ -44,6 +57,7 @@ website?: string; phone?: string; openingHours?: Record; + reviewStats?: ReviewStats; createdBy?: string; } @@ -55,6 +69,18 @@ let showDeleteConfirm = $state(false); let deleting = $state(false); + // Review state + let reviews = $state([]); + let reviewRating = $state(0); + let reviewComment = $state(''); + let submittingReview = $state(false); + let reviewError = $state(''); + let showReviewForm = $state(false); + + let userHasReviewed = $derived( + authStore.isAuthenticated && reviews.some((r) => r.userId === authStore.user?.id) + ); + // Gallery state let selectedImageIndex = $state(0); let showAddPhoto = $state(false); @@ -117,6 +143,9 @@ if (authStore.isAuthenticated) { favoritesStore.load(); } + + // Load reviews + loadReviews(); }); // Initialize mini map after location loads @@ -213,6 +242,76 @@ } } + async function loadReviews() { + if (!location) return; + try { + const res = await fetch(api(`/reviews/${location.id}`)); + if (res.ok) { + const data = await res.json(); + reviews = data.reviews || []; + } + } catch { + // ignore + } + } + + async function handleSubmitReview() { + if (!location || submittingReview || reviewRating === 0) return; + submittingReview = true; + reviewError = ''; + try { + const token = await authStore.getValidToken(); + const res = await fetch(api(`/reviews/${location.id}`), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + rating: reviewRating, + comment: reviewComment.trim() || undefined, + }), + }); + if (res.ok) { + reviewRating = 0; + reviewComment = ''; + showReviewForm = false; + await loadReviews(); + // Update review stats on the location + const statsRes = await fetch(api(`/reviews/${location.id}/stats`)); + if (statsRes.ok) { + const statsData = await statsRes.json(); + location = { ...location, reviewStats: statsData.stats }; + } + } else { + reviewError = $_('reviews.error'); + } + } catch { + reviewError = $_('reviews.error'); + } finally { + submittingReview = false; + } + } + + async function handleDeleteReview() { + if (!location) return; + try { + const token = await authStore.getValidToken(); + await fetch(api(`/reviews/${location.id}`), { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + await loadReviews(); + const statsRes = await fetch(api(`/reviews/${location.id}/stats`)); + if (statsRes.ok) { + const statsData = await statsRes.json(); + location = { ...location, reviewStats: statsData.stats }; + } + } catch { + // ignore + } + } + function formatDistance(meters: number): string { if (meters < 1000) return `${meters} m`; return `${(meters / 1000).toFixed(1)} km`; @@ -521,6 +620,138 @@ {/if} + +
+
+
+

{$_('reviews.title')}

+ {#if location.reviewStats && location.reviewStats.totalReviews > 0} +
+
+ {#each Array(5) as _, i} + + + + {/each} +
+ + {location.reviewStats.averageRating.toFixed(1)} + + + ({location.reviewStats.totalReviews}) + +
+ {/if} +
+ {#if authStore.isAuthenticated && !userHasReviewed} + + {/if} +
+ + + {#if showReviewForm} +
+

{$_('reviews.yourRating')}

+ {#if reviewError} +
+ {reviewError} +
+ {/if} +
+ {#each [1, 2, 3, 4, 5] as star} + + {/each} +
+ + +
+ {/if} + + + {#if reviews.length > 0} +
+ {#each reviews as review} +
+
+
+ {#each Array(5) as _, i} + + + + {/each} +
+
+ + {new Date(review.createdAt).toLocaleDateString()} + + {#if authStore.isAuthenticated && review.userId === authStore.user?.id} + + {/if} +
+
+ {#if review.comment} +

{review.comment}

+ {/if} +
+ {/each} +
+ {:else if !showReviewForm} +

{$_('reviews.noReviews')}

+ {/if} +
+ {#if isOwner}