# Embassy Alliance

*Staff edit the pages themselves, and one idle click once overwrote weeks of Russian proofreading.*

**Category:** WEB PLATFORM / TRAVEL
**Status:** live
**Period:** December 2025 – September 2026
**Live:** https://embassyalliance.com
**Built by:** HM Ammar, Digital Architect & Head of IT, Kuala Lumpur

**Stack:** Next.js 16, React 19, Payload CMS 3, TypeScript, PostgreSQL, Tailwind CSS, Puppeteer, nginx + PM2

## The challenge

Embassy Alliance Travel Group ran on WordPress: a large bilingual site, with a Singapore visa workflow bolted on as a custom post type. The content sat in ACF fields nobody could safely restructure, and the Russian half was maintained by a proofreader whose corrections existed nowhere but the live database. My rebuild had to keep a thousand trade agents' logins working, keep the Russian text intact, and let non-technical staff change wording and photographs without opening an admin panel.

## What I built

I built a Next.js application with Payload CMS embedded in it, 25 collections, Postgres in production, self-hosted on the client's own server behind nginx and PM2 rather than a platform. 263 route files cover destinations, hotels, tours, excursions, transfers, VIP and MICE pages, each mirrored in Russian. On top of Payload I put an inline-editing layer: adding ?edit=1 turns roughly 2,900 text slots and 90 media slots on the page itself into editable fields, saved per locale. Three staff portals share the same authentication — an agent zone with per-agent rate codes, a visa-manager dashboard running a thirteen-state application workflow, and a visa-submitter view.

## The detail

### Inline editing

Payload gives you an admin panel. The client wanted to fix a sentence where they read it. My <EB> wrapper renders the hardcoded string as its fallback and, in edit mode, becomes contentEditable and writes a localised override keyed "<route>:<block>", so a page nobody has touched needs no database row at all. A Puppeteer script I wrote walks a rendered page and reports every visible sentence no slot covers, because counting slots in the HTML tells you how many exist, not whether the third paragraph has one.

### The Russian guard

An idle click-and-blur in edit mode on an English page once saved the English fallback as a stored override, and Payload's locale fallback then served that row to every Russian page, undoing weeks of proofreading. I fixed both causes: no-change saves are skipped, and page-block reads run with locale fallback disabled. The more useful piece verifies the outcome instead of the cause — it flags any Russian override byte-identical to its English twin with no Cyrillic in it, then fetches live /ru pages and looks for English sentinel strings that have Russian counterparts in code. It ships as a nightly script that snapshots every override to a directory outside the repository, prunes past a retention window, then runs the guard.

### An authentication bypass

The visa-manager dashboard gated its pages with a check that ignored the token it was handed and read the role out of a cookie the browser could write, and the lookup behind it ran with Payload's own access control overridden. Forging that cookie was enough to read any application by id: passport numbers, dates of birth, addresses. Every authorisation path now verifies the JWT through payload.auth() and reads the role off the agent record, leaving the cookie only where it genuinely just renders a name. A second server action had no authentication at all, and I closed it the same way.

### Discarded enquiries

Sixteen form components post to /api/contact — the contact page, both newsletters, tour and excursion booking, the planning and VIP modals. The route composed an email, handed it to nodemailer without awaiting it, and returned success regardless. Production carries no SMTP credentials, so the send failed into a background catch while the visitor saw a thank-you screen. I now persist enquiries first and treat the email as a notification on top, recorded per row, so "did we get an enquiry" and "were we told about it" became separate questions.

### Agent-facing surfaces

The site advertised markdown negotiation and an agent-skills index. The markdown endpoint held a hardcoded table of thirteen paths; the other 145 pages fell through to a fixed company blurb returned as 200 with Content-Type: text/markdown, undetectable as a placeholder and so worse than offering nothing. It now fetches the page, scopes extraction to <main> and converts that, so coverage follows the routes. I build /llms.txt from the same route registry as the sitemap, which means a page cannot be listed unless its route file exists.

## The outcome

Live on embassyalliance.com, serving the domain that previously ran WordPress: the response headers show the Next.js and Payload application behind nginx, and the deployed /llms.txt matches what this repository generates. A separate staging host carries the client's in-progress Russian proofreading. I ran two spreadsheet imports to bring the trade agents across with their existing logins — the import script reported 1,111 agents in the database after the second, with one row skipped for having no password. The visa workflow, the agent zone and the CMS all run from a single Node process on the client's own infrastructure.

---

[All work](https://hmammar.world/) · [HTML version](https://hmammar.world/work/embassy-alliance.html)
