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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-25 20:28:23 +01:00
parent 1edbc190a6
commit 7303709710
12 changed files with 486 additions and 3 deletions

View file

@ -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_',

View file

@ -1,3 +1,4 @@
export * from './locations.schema';
export * from './favorites.schema';
export * from './collections.schema';
export * from './reviews.schema';

View file

@ -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;

View file

@ -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')

View file

@ -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],

View file

@ -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 };
}
}

View file

@ -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 {}

View file

@ -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<Review[]> {
return this.db
.select()
.from(reviews)
.where(eq(reviews.locationId, locationId))
.orderBy(desc(reviews.createdAt));
}
async getStats(locationId: string): Promise<ReviewStats> {
const [result] = await this.db
.select({
averageRating: sql<number>`coalesce(round(avg(${reviews.rating})::numeric, 1), 0)::float`,
totalReviews: sql<number>`count(*)::int`,
})
.from(reviews)
.where(eq(reviews.locationId, locationId));
return result || { averageRating: 0, totalReviews: 0 };
}
async getStatsForLocations(locationIds: string[]): Promise<Record<string, ReviewStats>> {
if (locationIds.length === 0) return {};
const results = await this.db
.select({
locationId: reviews.locationId,
averageRating: sql<number>`round(avg(${reviews.rating})::numeric, 1)::float`,
totalReviews: sql<number>`count(*)::int`,
})
.from(reviews)
.where(
sql`${reviews.locationId} = ANY(${sql.raw(`ARRAY[${locationIds.map((id) => `'${id}'::uuid`).join(',')}]`)})`
)
.groupBy(reviews.locationId);
const map: Record<string, ReviewStats> = {};
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<Review> {
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<void> {
await this.db
.delete(reviews)
.where(and(eq(reviews.userId, userId), eq(reviews.locationId, locationId)));
}
}

View file

@ -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.",

View file

@ -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.",

View file

@ -17,6 +17,7 @@
longitude?: number;
imageUrl?: string;
openingHours?: Record<string, string>;
reviewStats?: { averageRating: number; totalReviews: number };
createdBy?: string;
}
@ -282,6 +283,28 @@
<p class="mt-1 line-clamp-2 text-sm text-foreground-secondary">
{location.description}
</p>
{#if location.reviewStats && location.reviewStats.totalReviews > 0}
<div class="mt-2 flex items-center gap-1.5 text-xs text-foreground-secondary">
<div class="flex items-center gap-0.5">
{#each Array(5) as _, i}
<svg
class="h-3.5 w-3.5 {i < Math.round(location.reviewStats.averageRating)
? 'text-amber-400'
: 'text-gray-300 dark:text-gray-600'}"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
{/each}
</div>
<span>{location.reviewStats.averageRating.toFixed(1)}</span>
<span class="text-foreground-secondary/60">({location.reviewStats.totalReviews})</span
>
</div>
{/if}
</div>
</a>
{/each}

View file

@ -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<string, string>;
reviewStats?: ReviewStats;
createdBy?: string;
}
@ -55,6 +69,18 @@
let showDeleteConfirm = $state(false);
let deleting = $state(false);
// Review state
let reviews = $state<Review[]>([]);
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 @@
</div>
{/if}
<!-- Reviews -->
<div>
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<h2 class="text-xl font-semibold text-foreground">{$_('reviews.title')}</h2>
{#if location.reviewStats && location.reviewStats.totalReviews > 0}
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-0.5">
{#each Array(5) as _, i}
<svg
class="h-4 w-4 {i < Math.round(location.reviewStats.averageRating)
? 'text-amber-400'
: 'text-gray-300 dark:text-gray-600'}"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
{/each}
</div>
<span class="text-sm font-medium text-foreground">
{location.reviewStats.averageRating.toFixed(1)}
</span>
<span class="text-sm text-foreground-secondary">
({location.reviewStats.totalReviews})
</span>
</div>
{/if}
</div>
{#if authStore.isAuthenticated && !userHasReviewed}
<button
onclick={() => (showReviewForm = !showReviewForm)}
class="text-sm font-medium text-primary hover:underline"
>
{$_('reviews.write')}
</button>
{/if}
</div>
<!-- Review form -->
{#if showReviewForm}
<div class="mb-4 rounded-xl border border-border bg-background-card p-4">
<p class="mb-3 text-sm font-medium text-foreground">{$_('reviews.yourRating')}</p>
{#if reviewError}
<div class="mb-3 rounded-lg bg-red-500/10 p-2 text-xs text-red-500">
{reviewError}
</div>
{/if}
<div class="mb-3 flex gap-1">
{#each [1, 2, 3, 4, 5] as star}
<button
onclick={() => (reviewRating = star)}
class="transition-transform hover:scale-110"
>
<svg
class="h-8 w-8 {star <= reviewRating
? 'text-amber-400'
: 'text-gray-300 dark:text-gray-600'}"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
</button>
{/each}
</div>
<textarea
bind:value={reviewComment}
placeholder={$_('reviews.commentPlaceholder')}
rows="2"
class="mb-3 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary resize-none"
></textarea>
<button
onclick={handleSubmitReview}
disabled={reviewRating === 0 || submittingReview}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{submittingReview ? $_('reviews.submitting') : $_('reviews.submit')}
</button>
</div>
{/if}
<!-- Review list -->
{#if reviews.length > 0}
<div class="space-y-3">
{#each reviews as review}
<div class="rounded-xl border border-border bg-background-card p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
{#each Array(5) as _, i}
<svg
class="h-4 w-4 {i < review.rating
? 'text-amber-400'
: 'text-gray-300 dark:text-gray-600'}"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
{/each}
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-foreground-secondary">
{new Date(review.createdAt).toLocaleDateString()}
</span>
{#if authStore.isAuthenticated && review.userId === authStore.user?.id}
<button
onclick={handleDeleteReview}
class="text-xs text-red-500 hover:underline"
>
{$_('reviews.delete')}
</button>
{/if}
</div>
</div>
{#if review.comment}
<p class="mt-2 text-sm text-foreground-secondary">{review.comment}</p>
{/if}
</div>
{/each}
</div>
{:else if !showReviewForm}
<p class="text-sm text-foreground-secondary">{$_('reviews.noReviews')}</p>
{/if}
</div>
<!-- Owner actions -->
{#if isOwner}
<div class="flex gap-3">