feat(citycorners): add web lookup for new locations via mana-search

Backend: GET /locations/lookup?q= endpoint that searches via mana-search, extracts content from top results, auto-detects address and category, returns pre-filled data with source links.

Frontend: /add page now has a two-step flow:
1. Search step: user enters a place name, backend scrapes the web
2. Edit step: form pre-filled with found data (name, description, address, category), user can review/edit before submitting. Shows source links.

Also fixed all API paths to use /api/v1/ prefix via centralized api() helper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 12:23:24 +01:00
parent 416e031f69
commit 0f93496364
7 changed files with 323 additions and 10 deletions

View file

@ -0,0 +1,163 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface LookupResult {
name: string;
description: string;
address?: string;
category?: string;
sources: { url: string; title: string }[];
}
@Injectable()
export class LocationLookupService {
private readonly logger = new Logger(LocationLookupService.name);
private readonly searchUrl: string;
constructor(private readonly configService: ConfigService) {
this.searchUrl = this.configService.get<string>('MANA_SEARCH_URL') || 'http://localhost:3021';
}
async lookup(query: string): Promise<LookupResult | null> {
const searchQuery = `${query} Konstanz`;
try {
// Search for the location
const searchRes = await fetch(`${this.searchUrl}/api/v1/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: searchQuery,
options: { categories: ['general'], language: 'de-DE', limit: 5 },
}),
signal: AbortSignal.timeout(15000),
});
if (!searchRes.ok) {
this.logger.warn(`Search failed: ${searchRes.status}`);
return null;
}
const searchData = await searchRes.json();
const results = searchData.results || [];
if (results.length === 0) return null;
// Extract content from top 3 results
const topUrls = results.slice(0, 3).map((r: any) => r.url);
let extractedTexts: string[] = [];
try {
const extractRes = await fetch(`${this.searchUrl}/api/v1/extract/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
urls: topUrls,
options: { includeMarkdown: false, maxLength: 5000 },
concurrency: 3,
}),
signal: AbortSignal.timeout(20000),
});
if (extractRes.ok) {
const extractData = await extractRes.json();
extractedTexts = (extractData.results || [])
.filter((r: any) => r.success && r.content?.text)
.map((r: any) => r.content.text.substring(0, 2000));
}
} catch (err) {
this.logger.warn('Bulk extract failed, using search snippets', err);
}
// Combine search snippets + extracted text for the description
const snippets = results.map((r: any) => r.snippet).filter(Boolean);
const allText = [...extractedTexts, ...snippets].join('\n\n');
// Try to detect address from text
const address = this.extractAddress(allText);
// Try to guess category
const category = this.guessCategory(query, allText);
// Build a description from the best snippet or extracted text
const description = this.buildDescription(snippets, extractedTexts);
return {
name: query,
description,
address,
category,
sources: results.slice(0, 5).map((r: any) => ({
url: r.url,
title: r.title,
})),
};
} catch (err) {
this.logger.error('Lookup failed', err);
return null;
}
}
private extractAddress(text: string): string | undefined {
// Look for German address patterns (street + number + PLZ + city)
const addressPattern =
/(\b[A-ZÄÖÜ][a-zäöüß]+(?:straße|gasse|weg|platz|allee|ring)\s+\d+[\w]*,?\s*\d{5}\s+\w+)/i;
const match = text.match(addressPattern);
if (match) return match[1];
// Simpler: just street + number in Konstanz
const simplePattern =
/(\b[A-ZÄÖÜ][a-zäöüß]+(?:straße|gasse|weg|platz|allee|ring)\s+\d+[\w-]*)/i;
const simpleMatch = text.match(simplePattern);
if (simpleMatch) return `${simpleMatch[1]}, 78462 Konstanz`;
return undefined;
}
private guessCategory(query: string, text: string): string {
const lowerQuery = query.toLowerCase();
const lowerText = text.toLowerCase();
if (
/restaurant|essen|küche|dining|speise|bistro|gasth/i.test(
lowerQuery + ' ' + lowerText.substring(0, 500)
)
) {
return 'restaurant';
}
if (
/museum|ausstellung|galerie|sammlung/i.test(lowerQuery + ' ' + lowerText.substring(0, 500))
) {
return 'museum';
}
if (
/laden|shop|geschäft|boutique|markt|einkauf|shopping/i.test(
lowerQuery + ' ' + lowerText.substring(0, 500)
)
) {
return 'shop';
}
return 'sight';
}
private buildDescription(snippets: string[], extractedTexts: string[]): string {
// Prefer extracted text (more detailed)
if (extractedTexts.length > 0) {
const text = extractedTexts[0];
// Take first meaningful paragraph (at least 50 chars)
const paragraphs = text.split(/\n\n+/).filter((p) => p.trim().length > 50);
if (paragraphs.length > 0) {
const desc = paragraphs[0].trim();
return desc.length > 300 ? desc.substring(0, 297) + '...' : desc;
}
}
// Fall back to search snippets
if (snippets.length > 0) {
const combined = snippets.slice(0, 2).join(' ');
return combined.length > 300 ? combined.substring(0, 297) + '...' : combined;
}
return '';
}
}

View file

@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { LocationService } from './location.service';
import { LocationLookupService } from './location-lookup.service';
import { IsString, IsNotEmpty, IsOptional, IsNumber } from 'class-validator';
import { Type } from 'class-transformer';
@ -70,7 +71,10 @@ class UpdateLocationDto {
@Controller('locations')
export class LocationController {
constructor(private readonly locationService: LocationService) {}
constructor(
private readonly locationService: LocationService,
private readonly lookupService: LocationLookupService
) {}
@Get()
async findAll(@Query('category') category?: string) {
@ -78,6 +82,15 @@ export class LocationController {
return { locations };
}
@Get('lookup')
async lookup(@Query('q') query: string) {
if (!query || query.trim().length === 0) {
return { result: null };
}
const result = await this.lookupService.lookup(query.trim());
return { result };
}
@Get('search')
async search(@Query('q') query: string) {
if (!query || query.trim().length === 0) {

View file

@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { LocationController } from './location.controller';
import { LocationService } from './location.service';
import { LocationLookupService } from './location-lookup.service';
@Module({
controllers: [LocationController],
providers: [LocationService],
providers: [LocationService, LocationLookupService],
exports: [LocationService],
})
export class LocationModule {}

View file

@ -86,6 +86,13 @@
"minChars": "Mindestens 10 Zeichen",
"address": "Adresse (optional)",
"addressPlaceholder": "z.B. Seestraße 1, 78462 Konstanz",
"searchTitle": "Ort im Web suchen",
"searchSubtitle": "Wir suchen automatisch nach Infos und füllen das Formular vor.",
"searchPlaceholder": "z.B. Café Zeitlos Konstanz",
"searchButton": "Suchen",
"skipSearch": "Überspringen und manuell eintragen",
"foundSources": "Quellen gefunden:",
"reset": "Zurück",
"submit": "Ort einreichen",
"submitting": "Wird eingereicht...",
"loginRequired": "Melde dich an, um Orte hinzuzufügen.",

View file

@ -86,6 +86,13 @@
"minChars": "At least 10 characters",
"address": "Address (optional)",
"addressPlaceholder": "e.g. Seestraße 1, 78462 Konstanz",
"searchTitle": "Search for a place online",
"searchSubtitle": "We'll automatically find info and pre-fill the form for you.",
"searchPlaceholder": "e.g. Café Zeitlos Konstanz",
"searchButton": "Search",
"skipSearch": "Skip and enter manually",
"foundSources": "Sources found:",
"reset": "Back",
"submit": "Submit place",
"submitting": "Submitting...",
"loginRequired": "Sign in to add places.",

View file

@ -4,6 +4,13 @@
import { authStore } from '$lib/stores/auth.svelte';
import { api } from '$lib/api';
// Lookup state
let searchQuery = $state('');
let searching = $state(false);
let lookupDone = $state(false);
let sources = $state<{ url: string; title: string }[]>([]);
// Form state
let name = $state('');
let category = $state<string>('sight');
let description = $state('');
@ -20,6 +27,55 @@
let isValid = $derived(name.trim().length > 0 && description.trim().length > 10);
async function handleLookup() {
if (!searchQuery.trim() || searching) return;
searching = true;
error = '';
sources = [];
try {
const res = await fetch(api(`/locations/lookup?q=${encodeURIComponent(searchQuery.trim())}`));
if (!res.ok) throw new Error('Lookup failed');
const data = await res.json();
if (data.result) {
name = data.result.name || searchQuery.trim();
description = data.result.description || '';
address = data.result.address || '';
category = data.result.category || 'sight';
sources = data.result.sources || [];
} else {
name = searchQuery.trim();
}
lookupDone = true;
} catch {
// Fallback: just use the search query as name
name = searchQuery.trim();
lookupDone = true;
} finally {
searching = false;
}
}
function handleSkipLookup() {
name = searchQuery.trim();
lookupDone = true;
}
function handleReset() {
lookupDone = false;
searchQuery = '';
name = '';
description = '';
address = '';
category = 'sight';
sources = [];
error = '';
}
async function handleSubmit() {
if (!isValid || submitting) return;
@ -82,7 +138,64 @@
{$_('settings.login')}
</a>
</div>
{:else if !lookupDone}
<!-- Step 1: Search for the location online -->
<div class="space-y-4">
<div class="rounded-xl border border-border bg-background-card p-5">
<h2 class="mb-1 text-lg font-semibold text-foreground">{$_('add.searchTitle')}</h2>
<p class="mb-4 text-sm text-foreground-secondary">{$_('add.searchSubtitle')}</p>
<div class="flex gap-2">
<input
type="text"
bind:value={searchQuery}
placeholder={$_('add.searchPlaceholder')}
class="flex-1 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"
onkeydown={(e) => e.key === 'Enter' && handleLookup()}
/>
<button
onclick={handleLookup}
disabled={!searchQuery.trim() || searching}
class="rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if searching}
<div
class="h-5 w-5 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
{:else}
{$_('add.searchButton')}
{/if}
</button>
</div>
</div>
<button
onclick={handleSkipLookup}
class="w-full text-center text-sm text-foreground-secondary hover:text-primary transition-colors"
>
{$_('add.skipSearch')}
</button>
</div>
{:else}
<!-- Step 2: Edit and submit -->
{#if sources.length > 0}
<div class="mb-5 rounded-lg bg-primary/5 border border-primary/20 p-3">
<p class="mb-2 text-xs font-medium text-primary">{$_('add.foundSources')}</p>
<div class="space-y-1">
{#each sources.slice(0, 3) as source}
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
class="block truncate text-xs text-foreground-secondary hover:text-primary"
>
{source.title}
</a>
{/each}
</div>
</div>
{/if}
<form
onsubmit={(e) => {
e.preventDefault();
@ -153,12 +266,21 @@
/>
</div>
<button
type="submit"
disabled={!isValid || submitting}
class="w-full rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? $_('add.submitting') : $_('add.submit')}
</button>
<div class="flex gap-3">
<button
type="button"
onclick={handleReset}
class="rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover"
>
{$_('add.reset')}
</button>
<button
type="submit"
disabled={!isValid || submitting}
class="flex-1 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? $_('add.submitting') : $_('add.submit')}
</button>
</div>
</form>
{/if}

View file

@ -732,9 +732,9 @@ services:
DB_PORT: 5432
DB_USER: postgres
MANA_CORE_AUTH_URL: http://mana-auth:3001
MANA_SEARCH_URL: http://mana-search:3020
CORS_ORIGINS: https://citycorners.mana.how,https://mana.how
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
SEED_ON_START: "true"
ports:
- "3041:3041"
healthcheck: