Automated Translation Service for Property Listings in PlayamxCRM API
TL;DR: I added a TranslateListingService to the PlayamxCRM API that auto‑translates property descriptions to English using Google Cloud Translation, and wired it into the listing‑update pipeline. The change removed a manual copy‑paste step and fixed the “Object Not Found Matching Id” error that was breaking our weekly‑summary automation.
The Problem
Our weekly‑summary generator pulls the latest property listings from the API, builds a markdown report and pushes it to Dev.to, Bluesky and Substack. On September 4 2026 the job started failing with the following stack trace:
UnhandledRejection: Non‑Error promise rejection captured with value:
Object Not Found Matching Id:1, MethodName:update, ParamCount:2
at ListingService.update (apps/api/src/modules/listing/listing.service.ts:87:15)
at processTicksAndRejections (node:internal/process/task_queues:96:5)
The root cause was a tiny typo in the description field: a few Spanish listings contained characters that broke the downstream markdown renderer. The fix we needed was to ensure every description is stored in English, regardless of the original language, before the summary job runs.
What I Tried First
My first attempt was a quick‑and‑dirty wrapper around the free LibreTranslate HTTP endpoint:
// apps/api/src/shared/translate-listing.service.ts (initial version)
import fetch from 'node-fetch';
export async function translate(text: string): Promise<string> {
const resp = await fetch('https://libretranslate.de/translate', {
method: 'POST',
body: JSON.stringify({
q: text,
source: 'auto',
target: 'en',
}),
headers: { 'Content-Type': 'application/json' },
});
const data = await resp.json();
return data.translatedText;
}
I called this function from ListingService.update right after the DB write. The API started returning 429 Too Many Requests after a handful of calls, and the promise rejection bubbled up uncaught, reproducing the exact error we saw in Sentry.
Why it failed:
- Rate limiting – LibreTranslate is a shared public instance; our batch of ~200 listings per day exceeded its quota.
-
No retry / fallback – The function threw on any non‑200 response, and the caller didn’t wrap it in a
try/catch. -
No type safety – The response shape was assumed, leading to runtime
undefinederrors when the API changed.
The Implementation
1. Choose a production‑grade provider
Google Cloud Translation (v3) offers a paid, high‑throughput endpoint with built‑in authentication and quota management. I added the @google-cloud/translate package:
npm i @google-cloud/translate
2. Refactor into a NestJS injectable service
// apps/api/src/shared/translate-listing.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { TranslationServiceClient } from '@google-cloud/translate';
import LRUCache from 'lru-cache';
export interface TranslateResult {
text: string;
detectedSourceLanguage: string;
}
@Injectable()
export class TranslateListingService {
private readonly logger = new Logger(TranslateListingService.name);
private readonly client: TranslationServiceClient;
private readonly cache: LRUCache<string, TranslateResult>;
constructor() {
// The client reads GOOGLE_APPLICATION_CREDENTIALS from env
this.client = new TranslationServiceClient();
this.cache = new LRUCache({ max: 500, ttl: 1000 * 60 * 60 }); // 1 h
}
async translate(text: string): Promise<string> {
if (!text) return '';
// Simple in‑memory cache to avoid duplicate calls
const cached = this.cache.get(text);
if (cached) {
this.logger.debug('Cache hit for translation');
return cached.text;
}
try {
const [response] = await this.client.translateText({
parent: `projects/${process.env.GCP_PROJECT_ID}/locations/global`,
contents: [text],
mimeType: 'text/plain',
targetLanguageCode: 'en',
});
const result: TranslateResult = {
text: response.translations?.[0].translatedText ?? '',
detectedSourceLanguage:
response.translations?.[0].detectedLanguageCode ?? 'und',
};
this.cache.set(text, result);
this.logger.verbose(
`Translated from ${result.detectedSourceLanguage} → en`,
);
return result.text;
} catch (err) {
this.logger.error('Translation failed', err);
// Fallback: return original text so the pipeline keeps running
return text;
}
}
}
Key decisions:
| Decision | Reason |
|---|---|
| NestJS Injectable | Keeps the service in the same DI container as other API services, making testing trivial. |
| LRU in‑memory cache | Reduces cost and latency for repeated descriptions (e.g., templated “Studio apartment”). |
| Graceful fallback | Guarantees the listing update never throws because of translation; we prefer a raw Spanish string over a broken job. |
| Environment‑driven credentials | No secrets in code; the Google client picks up GOOGLE_APPLICATION_CREDENTIALS. |
3. Wire the service into the listing flow
// apps/api/src/modules/listing/listing.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { TranslateListingService } from '../../shared/translate-listing.service';
@Injectable()
export class ListingService {
constructor(
private readonly prisma: PrismaService,
private readonly translator: TranslateListingService,
) {}
async update(id: number, dto: UpdateListingDto) {
const listing = await this.prisma.listing.findUnique({ where: { id } });
if (!listing) throw new NotFoundException(`Listing ${id} not found`);
// Translate description if needed
const englishDesc = await this.translator.translate(dto.description);
const data = { ...dto, description_en: englishDesc };
return this.prisma.listing.update({
where: { id },
data,
});
}
}
Notice the await on translator.translate. The previous version called a fire‑and‑forget function, which left the promise unhandled and produced the “Non‑Error promise rejection” we saw.
4. Register the provider
Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/content-automation · 2026-09-05
#playadev #buildinpublic
Top comments (0)