Skip to content

feat: content moderation + campaign i18n (translations)#717

Merged
joelpeace48-cell merged 2 commits into
FinesseStudioLab:mainfrom
Grandida-Projects:Wilfred-implementation
Jun 29, 2026
Merged

feat: content moderation + campaign i18n (translations)#717
joelpeace48-cell merged 2 commits into
FinesseStudioLab:mainfrom
Grandida-Projects:Wilfred-implementation

Conversation

@Wilfred007

@Wilfred007 Wilfred007 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Overview

Two independent backend features shipped together: automated pre-publish content moderation to keep spam and abuse off the platform, and a full i18n layer so campaigns can reach non-English audiences with localized names and descriptions.


Content Moderation

Campaigns are user-controlled text surfaces. Without a gate, spam, phishing lures, and offensive content would go live immediately. This adds a configurable moderation check that runs before any campaign is created or updated.

How it works

The check is middleware — it sits between auth and the route handler on POST /campaigns and PUT /campaigns/:id. It reads name, description, and tags from the request body, runs them through the configured provider, and either passes the request through or returns 422 with the flagged categories before the DB is ever touched.

Three providers, zero required config

┌─────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐
│ MODERATION_PROVIDER │ Behaviour │
├─────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ local (default) │ Keyword blocklist loaded from backend/src/moderation/blocklist.json — no API calls, no cost │
├─────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ openai │ OpenAI Moderation API (free tier) — set OPENAI_API_KEY │
├─────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ none │ Disabled entirely │
└─────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘

Admin override

Operators using an env-configured API key (source: 'env') can bypass the check by including "override_moderation": true in the request body. Regular database-backed API keys cannot override.

Runtime blocklist management

GET /api/v1/admin/moderation/blocklist — inspect current terms
POST /api/v1/admin/moderation/blocklist — { action: "add"|"remove", term }
Both require the master key. Edits persist to blocklist.json immediately without a restart.

Response on flag
HTTP 422
{ "error": "Content violates community guidelines", "categories": ["spam"] }

All flags are logged at INFO level with campaign ID and categories.

Files

  • backend/src/moderation/blocklist.json — 10 default blocked terms
  • backend/src/moderation/moderationService.js — provider-agnostic service; injectable for tests
  • backend/src/middleware/contentModeration.js — Express middleware
  • backend/src/moderation/moderation.test.js — 13 tests (7 unit + 6 HTTP integration)

Campaign i18n (Translations)

Campaigns previously had a single name and description — English only. This adds a translations JSON column to the campaigns table and a full locale-negotiation layer so any GET response automatically returns content in the requester's preferred language.

Storage

A translations TEXT NOT NULL DEFAULT '{}' column stores a JSON map:
{ "es": { "name": "Mi Campaña", "description": "Descripción" }, "fr": { "name": "Ma Campagne" } }
Added via migration 027_campaign_translations.js — fully backwards compatible, existing rows default to {}.

Locale negotiation

On every GET /campaigns, GET /campaigns/:id, and GET /campaigns/by-slug/:slug, the server:

  1. Checks for a ?locale=es query parameter (takes precedence)
  2. Falls back to parsing the Accept-Language header (respects q-values)
  3. Falls back to English (default name/description) if no translation matches

Language subtag fallback is supported — a request for zh-CN will match a stored zh translation if zh-CN is not present.

API

PUT /api/v1/campaigns/:id/translations/:locale — upsert a translation (API key + campaigns:write)
GET /api/v1/campaigns/:id/translations — return all stored translations (API key)
DELETE /api/v1/campaigns/:id/translations/:locale — remove a locale (API key + campaigns:write)

Constraints enforced

  • Locale must be valid BCP-47 (es, fr, zh-CN, pt-BR, zh-Hant-TW, …)
  • Max 10 locales per campaign → 422 LOCALE_LIMIT_EXCEEDED
  • Max 2 KB per translation → 413 TRANSLATION_TOO_LARGE

Campaign response shape (new fields)
{
"available_locales": ["es", "fr"],
"name": "Mi Campaña", ← overridden when locale matched
"description": "En español" ← overridden when locale matched
}
_rawTranslations is an internal field only — it is never serialised into a response.

Cache behaviour

The short-lived list cache (5 s) stores raw campaign data (with _rawTranslations). Locale negotiation is applied per-request after retrieval, so all locale variants share the same cache entry rather than each needing a separate slot.

Files

  • backend/src/db/migrations/027_campaign_translations.js — schema migration
  • backend/src/dal/sqliteCampaignRepository.js — rowToCampaign (adds available_locales, _rawTranslations), getTranslations, upsertTranslation, deleteTranslation
  • backend/src/index.js — locale helpers (isValidLocale, parseAcceptLanguage, getRequestLocales, serializeCampaign), updated GET handlers, new translation routes
  • backend/src/routes/translations.test.js — 19 tests (BCP-47 validation, repo CRUD, HTTP negotiation, fallback, limits)

Test plan

  • node --test backend/src/moderation/moderation.test.js — 13/13 pass
  • node --test backend/src/routes/translations.test.js — 19/19 pass
  • POST /campaigns with a blocked term (e.g. "name": "Buy Now") → 422
  • Same request with override_moderation: true on an env API key → 201
  • Set MODERATION_PROVIDER=none → all campaigns pass through unchecked
  • PUT /campaigns/:id/translations/es → 200; GET /campaigns/:id?locale=es → Spanish name
  • GET /campaigns/:id with Accept-Language: fr,en;q=0.9 → French name if fr translation exists
  • GET /campaigns/:id?locale=de with no German translation → English name (fallback)
  • PUT /campaigns/:id/translations/not_valid → 400 INVALID_LOCALE
  • Add 10 locales then attempt an 11th → 422 LOCALE_LIMIT_EXCEEDED
  • Submit a translation with description > 2 KB → 413 TRANSLATION_TOO_LARGE

Closes #464
Closes #461
Closes #459
Closes #459

@vercel

vercel Bot commented Jun 29, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the joelpeace48-cell's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Jun 29, 2026

Copy link
Copy Markdown

@Wilfred007 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Comment thread backend/src/index.js
.split(',')
.map((part) => {
const [tag, qPart] = part.trim().split(';');
const q = qPart ? parseFloat(qPart.replace(/.*=/, '')) : 1.0;
}

function saveBlocklistFile(path, terms) {
writeFileSync(path, JSON.stringify({ terms }, null, 2) + '\n', 'utf8');
@joelpeace48-cell joelpeace48-cell merged commit 4bebab0 into FinesseStudioLab:main Jun 29, 2026
3 of 15 checks passed
joelpeace48-cell added a commit that referenced this pull request Jun 30, 2026
feat: content moderation + campaign i18n (translations)
joelpeace48-cell added a commit that referenced this pull request Jun 30, 2026
feat: content moderation + campaign i18n (translations)
joelpeace48-cell added a commit that referenced this pull request Jun 30, 2026
feat: content moderation + campaign i18n (translations)
joelpeace48-cell added a commit that referenced this pull request Jun 30, 2026
feat: content moderation + campaign i18n (translations)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants