<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Dental Link Vietnam]]></title><description><![CDATA[Dental Link (Dentallinkvn.com) is the best dental concierge in Vietnam. Dental Link matches international patients with vetted dental clinics in Ho Chi Minh Cit]]></description><link>https://dentallinkvn.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6acc06732817c48a4080dd15/70787acd-6cc0-4fae-b0a3-47bddadb6cac.png</url><title>Dental Link Vietnam</title><link>https://dentallinkvn.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 09:58:47 GMT</lastBuildDate><atom:link href="https://dentallinkvn.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How we keep a website quiz and a CRM in sync on Cloudflare Pages and D1 with contract tests]]></title><description><![CDATA[We run a small service business whose website and internal CRM live in the same Cloudflare Pages project. The public site, the CRM screens and the API are…]]></description><link>https://dentallinkvn.hashnode.dev/how-we-keep-a-website-quiz-and-a-crm-in-sync-on-cloudflare-pages-and-d1-with-contract-tests</link><guid isPermaLink="true">https://dentallinkvn.hashnode.dev/how-we-keep-a-website-quiz-and-a-crm-in-sync-on-cloudflare-pages-and-d1-with-contract-tests</guid><dc:creator><![CDATA[Dental Link Vietnam]]></dc:creator><pubDate>Wed, 14 Oct 2026 11:16:58 GMT</pubDate><content:encoded><![CDATA[<p>We run a small service business whose website and internal CRM live in the same Cloudflare Pages project. The public site, the CRM screens and the API are one deploy: static pages in <code>public/</code>, Pages Functions in <code>functions/</code>, and one D1 (SQLite) database behind both. The quiz on the website is the CRM's lead intake. When a visitor answers it, the answers are posted to an API endpoint and become a lead that the team works from.</p>
<p>That setup is cheap and fast to ship. It also means a change on one side can quietly break the other, and nothing crashes when it does.</p>
<h2>The failures we kept hitting</h2>
<p>None of these threw an error. Each one just made the product slightly wrong:</p>
<ul>
<li><p><strong>A renamed quiz field.</strong> The form posted a field under a new name while the endpoint still read the old one, so that answer never reached the lead.</p>
</li>
<li><p><strong>A button calling a route that no longer existed.</strong> The CRM UI called an <code>/api/...</code> path that had been moved. The button looked fine and returned a 404 into the console.</p>
</li>
<li><p><strong>A stage string with a typo.</strong> Leads move through named stages. One code path wrote a stage name that was not in the list, so those leads sat in a stage that nothing ever acted on.</p>
</li>
<li><p><strong>Two pages disagreeing.</strong> A copy edit changed a number on one page but not on another. The site contradicted itself for a day, and a plain string search did not find it because the number sat in its own <code>&lt;span&gt;</code>.</p>
</li>
</ul>
<p>Unit tests did not catch these, because each half was correct on its own. The bug was in the contract between them.</p>
<h2>The fix: one script that reads both halves</h2>
<p>We wrote <code>contract_sync.py</code>, a plain Python script with no model judgement in it. It reads the repo as text and checks the places where the site and the CRM touch:</p>
<table>
<thead>
<tr>
<th>It checks</th>
<th>Because otherwise</th>
</tr>
</thead>
<tbody><tr>
<td>Every <code>/api/...</code> path the UI calls maps to a real file under <code>functions/api/</code></td>
<td>a button 404s in silence</td>
</tr>
<tr>
<td>Every field the quiz posts is read by the leads endpoint</td>
<td>an answer is silently discarded</td>
</tr>
<tr>
<td>Every stage string in the code is a real stage and has a label</td>
<td>a lead stalls in a stage nothing acts on</td>
</tr>
<tr>
<td>Lead tiers are exactly one fixed set</td>
<td>routing and message templates disagree</td>
</tr>
<tr>
<td>No page contradicts the core service promises</td>
<td>the site says two different things</td>
</tr>
<tr>
<td>Every team member named on the site exists in the seed data</td>
<td>the CRM cannot assign work to them</td>
</tr>
<tr>
<td>A key number appears as one value everywhere</td>
<td>the site contradicts itself</td>
</tr>
</tbody></table>
<p>The route check is the simplest example. Pages Functions map files to URLs, so the script walks <code>functions/api/</code> and turns each file path into the route it serves:</p>
<pre><code class="language-python">def api_routes():
    routes = set()
    for p in glob.glob(os.path.join(ROOT, "functions", "api", "**", "*.js"), recursive=True):
        r = rel(p)[len("functions"):-3].replace(os.sep, "/")   # /api/leads/index
        if r.endswith("/index"):
            r = r[:-6] or "/"
        routes.add(re.sub(r"\[[^\]]+\]", "*", r))              # [id] becomes *
    return routes
</code></pre>
<p>Then it collects every <code>/api/...</code> string the front end calls and fails on any that has no matching route. Exit code 0 means the halves agree. Exit code 1 prints exactly what drifted.</p>
<p>Two details made it trustworthy:</p>
<ol>
<li><p><strong>Compare tag stripped text, not raw HTML.</strong> Our number mismatch survived a global replace because the number sat in its own element. The copy checks strip tags first and compare what a reader actually sees.</p>
</li>
<li><p><strong>One rule for new coupling.</strong> Whenever someone adds a new point where the site and the CRM touch, they add a check for it in the same change. That is how the list stays complete without anyone having to remember.</p>
</li>
</ol>
<h2>Testing the test</h2>
<p>A gate that never fails proves nothing. So we wrote a negative control: a shell script that plants known violations into a copy of the project (a renamed field, a missing route, a bad stage and so on), runs the checker, and expects every one to be caught. When we first set it up it planted 7 violations and caught 7.</p>
<p>One lesson from building that control: make the backup before you plant anything, and assert the backup exists. An early version used <code>cp a || cp b</code>, which skips the second copy when the first succeeds, left the restore path empty, and would have left the planted bugs in the working tree.</p>
<h2>Other traps on Cloudflare we learned the hard way</h2>
<ul>
<li><p><strong>PBKDF2 iterations.</strong> 120,000 iterations passed every local test and then threw an uncaught exception (error 1101) in production. Pinning to 100,000 fixed it.</p>
</li>
<li><p><strong>D1 has no migration ledger unless you build one.</strong> We apply migrations by hand, so the only reliable way to know what is applied is to probe the schema for each migration's tables and columns.</p>
</li>
<li><p><strong>A column no code writes.</strong> One column existed in the schema, the query and the UI, and every row still read 0, because the INSERT never set it. Syntax checks and an end to end test both passed. Now, after a feature runs, we read the row it should have written.</p>
</li>
<li><p><strong>A deploy turns on every pending setting.</strong> Pages applies all environment variables and secrets at the next deploy, not just your files. A pure copy change once switched on outbound email because a secret had been added earlier. We now compare setting names against the live deployment before every upload.</p>
</li>
</ul>
<h2>What it costs</h2>
<p>The checker is one file of standard library Python, so it runs before every deploy along with our other gates, and a red result blocks the release.</p>
<p>If your website and your back office share a database, the cheapest test you can write is the one that reads both halves and checks they still agree.</p>
<hr />
<p><em>We build and run</em> <a href="https://www.dentallinkvn.com/"><em>Dental Link</em></a><em>, a dental concierge for international patients in Ho Chi Minh City and Hanoi, Vietnam. The platform described here is its website and CRM.</em></p>
]]></content:encoded></item></channel></rss>