DEV Community

Roberto Luna
Roberto Luna

Posted on

Automated Translation Service for Property Listings in PlayamxCRM API

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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Rate limiting – LibreTranslate is a shared public instance; our batch of ~200 listings per day exceeded its quota.
  2. No retry / fallback – The function threw on any non‑200 response, and the caller didn’t wrap it in a try/catch.
  3. No type safety – The response shape was assumed, leading to runtime undefined errors 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
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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)