places: show the community's published pictures first in Pick a picture

The service delivers the pictures review approved for a place, but TREK never
read them. A new TrekCommunityService in the maps domain asks the TREK Places
API for them (the index row for a gers place, /v1/community for an OSM one),
accepts only paths of the service's picture store, and fetches the bytes with
the installation's key, capped at 4 MB. Enrichment puts them ahead of Google
and Commons, through the same photo cache, credited as TREK Community under
CC BY 4.0. The enrichment cache now follows the community version for OSM
places too, so a newly approved picture shows on the next open.

Also lowers the client's no-non-null-assertion baseline to 380: moving Use
this text into the description card removed two.
This commit is contained in:
Maurice
2026-10-30 23:04:12 +01:00
parent 0b86a13b51
commit 330644e88b
14 changed files with 249 additions and 17 deletions
+1 -6
View File
@@ -1,4 +1,5 @@
import { buildOsmDetails } from './maps.helpers';
import { communityKeyOf } from './trek-community.service';
import { trekCommunityByKeys, type TrekCommunityValues } from './trek-places.client';
import { normalizePlaceWebsite } from '@trek/shared';
@@ -10,12 +11,6 @@ import { normalizePlaceWebsite } from '@trek/shared';
* the same place everywhere.
*/
/** The service's key of an OSM details id: `relation:3389505` is `osm:relation/3389505`. */
export function communityKeyOf(placeId: string): string | null {
const match = /^(node|way|relation):(\d+)$/.exec(placeId);
return match ? `osm:${match[1]}/${match[2]}` : null;
}
/** The details record with what the community published laid over it; untouched where nothing was. */
export function withCommunityValues(
details: Record<string, unknown>,
+3 -1
View File
@@ -18,6 +18,7 @@ import { GooglePlacesClient } from './providers/google-places.provider';
import { OsmClient } from './providers/osm.client';
import { PhotonClient } from './providers/photon.client';
import { WikimediaClient } from './providers/wikimedia.client';
import { TrekCommunityService } from './trek-community.service';
import { TrekPlacesLayersService } from './trek-places-layers.service';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
@@ -66,7 +67,8 @@ import { Module } from '@nestjs/common';
PlaceDetailsResolver,
PoisAlongResolver,
TrekPlacesLayersService,
TrekCommunityService,
],
exports: [MapsService, GooglePlacesClient, OsmClient, WikimediaClient, TrekPlacesLayersService],
exports: [MapsService, GooglePlacesClient, OsmClient, WikimediaClient, TrekPlacesLayersService, TrekCommunityService],
})
export class MapsModule {}
@@ -0,0 +1,72 @@
import { trekCommunityByKeys, trekPlacesBaseUrl, trekPlacesById } from './trek-places.client';
import { identifiedFetch } from './trek-places.fetch';
import { Injectable } from '@nestjs/common';
/** A published picture's path as the service writes it; anything else is never fetched. */
const IMAGE_PATH = /^\/v1\/contrib\/img\/[A-Za-z0-9_-]{8,64}\.webp$/;
const IMAGE_MAX_BYTES = 4_000_000;
const IMAGE_TIMEOUT_MS = 8000;
/** The service's key of an OSM details id: `relation:3389505` is `osm:relation/3389505`. */
export function communityKeyOf(placeId: string): string | null {
const match = /^(node|way|relation):(\d+)$/.exec(placeId);
return match ? `osm:${match[1]}/${match[2]}` : null;
}
/**
* The pictures the TREK community published for a place, read from the TREK
* Places API: which ones (cover first, then the order review set) and their
* bytes. An index place carries them on its own row, an OSM place gets them
* from /v1/community. The caller checks that the API is switched on.
*/
@Injectable()
export class TrekCommunityService {
/** Paths of the place's published pictures, cover first; empty when there are none or the API fails. */
async photoPaths(placeId: string): Promise<string[]> {
let images: { url?: string; cover?: boolean }[] | null | undefined;
try {
if (placeId.startsWith('gers:')) {
images = (await trekPlacesById(placeId.slice('gers:'.length)))?.images;
} else {
const key = communityKeyOf(placeId);
if (key) images = (await trekCommunityByKeys([key]))[key]?.images;
}
} catch {
// The service being down must never cost the place its other pictures.
return [];
}
if (!Array.isArray(images)) return [];
const ordered = [...images.filter((i) => i?.cover), ...images.filter((i) => !i?.cover)];
return ordered.map((i) => i?.url).filter((u): u is string => typeof u === 'string' && IMAGE_PATH.test(u));
}
/**
* The bytes of one published picture, fetched with this installation's key,
* or null. Only a path of the service's own picture store is accepted: the
* value comes from the service's answer and becomes an authenticated download.
*/
async imageBytes(path: string): Promise<Buffer | null> {
if (!IMAGE_PATH.test(path)) return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), IMAGE_TIMEOUT_MS);
try {
const res = await identifiedFetch(
new URL(trekPlacesBaseUrl() + path),
{ Accept: 'image/webp' },
controller.signal,
);
if (!res.ok) return null;
const declared = Number(res.headers.get('content-length') ?? 0);
if (declared > IMAGE_MAX_BYTES) {
await res.body?.cancel();
return null;
}
const bytes = Buffer.from(await res.arrayBuffer());
return bytes.length && bytes.length <= IMAGE_MAX_BYTES ? bytes : null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
}
@@ -98,6 +98,7 @@ export interface TrekPlace {
hours: { osm: string; source: string | null; sourceUrl: string | null } | null;
/** Quoted from the place's own site, when it publishes one for machines. */
description?: { text: string; source: string | null; sourceUrl: string | null } | null;
images?: { url?: string; cover?: boolean }[] | null;
/** Bumped by the service whenever the community values it overlays for this place change. */
community?: { version?: number } | null;
score?: number;
@@ -642,6 +643,7 @@ export interface TrekCommunityValues {
contact?: { website?: string | null; phone?: string | null; email?: string | null } | null;
hours?: { osm?: string | null } | null;
description?: { text?: string | null } | null;
images?: { url?: string; cover?: boolean }[] | null;
community?: { version?: number; fields?: string[] } | null;
}
@@ -23,6 +23,7 @@ import {
import { GooglePlacesClient } from '../maps/providers/google-places.provider';
import { OsmClient } from '../maps/providers/osm.client';
import { WikimediaClient } from '../maps/providers/wikimedia.client';
import { communityKeyOf, TrekCommunityService } from '../maps/trek-community.service';
import { trekPlacesById } from '../maps/trek-places.client';
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import { InjectRepository } from '@mikro-orm/nestjs';
@@ -165,15 +166,15 @@ interface CachePayload extends CachedEnrichment {
}
/**
* The community version the index serves for a gers place, from the details
* record the request carries; null for any other place, or when it says none.
* The community version the service serves for a gers or OSM place, from the
* details record the request carries; null for any other place, or when it says none.
* A cached answer written under another version is read again, so a value
* another installation contributed shows up on the next open, not a week later.
* A sender who puts another number here only makes their own request compute
* afresh: nothing of the record is what gets cached.
*/
function communityVersion(placeId: string, details: Record<string, unknown> | null | undefined): number | null {
if (!placeId.startsWith('gers:')) return null;
if (!placeId.startsWith('gers:') && !communityKeyOf(placeId)) return null;
const version = details?.community_version;
return typeof version === 'number' && Number.isInteger(version) && version >= 0 ? version : null;
}
@@ -382,6 +383,7 @@ export class PlaceEnrichmentService {
private readonly googlePlaces: GooglePlacesClient,
private readonly osm: OsmClient,
private readonly wiki: WikimediaClient,
private readonly community: TrekCommunityService,
) {}
/**
@@ -588,8 +590,26 @@ export class PlaceEnrichmentService {
// One ranking pass over everything: the same file reaches us from several
// rungs and only the page id catches that.
const ranked = rankCommonsCandidates(commonsPool, COMMONS_CAP, { perAuthor: curated > 0 ? 2 : 1 });
// Google leads the strip — its pictures are of the business itself.
// The community's pictures lead: a person checked each one in review, and
// they show the place as someone who was there saw it. Then Google, whose
// pictures are of the business itself.
// Only index and OSM places have any; off means nothing leaves for the API.
const communityPlace = placeId.startsWith('gers:') || communityKeyOf(placeId) !== null;
const community = communityPlace && this.maps.trekPlacesEnabled() ? await this.community.photoPaths(placeId) : [];
const pending: PendingCandidate[] = [
...community.map((path) => ({
identity: `community:${path}`,
candidate: {
key: '',
url: '',
attribution: 'TREK Community',
license: 'CC BY 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by/4.0/',
sourceUrl: null,
source: 'community' as const,
},
fetchBytes: () => this.community.imageBytes(path),
})),
...googleRefs.map((ref) => ({
identity: `google:${ref.name}`,
candidate: {