feat: content moderation + campaign i18n (translations)#717
Merged
joelpeace48-cell merged 2 commits intoJun 29, 2026
Merged
Conversation
|
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. |
|
@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! 🚀 |
| .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'); |
4bebab0
into
FinesseStudioLab:main
3 of 15 checks passed
8 tasks
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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:
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
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
Test plan
Closes #464
Closes #461
Closes #459
Closes #459