<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: shaoor ahmad</title>
    <description>The latest articles on DEV Community by shaoor ahmad (@shaoor_ahmad_f9ab22689b95).</description>
    <link>https://dev.to/shaoor_ahmad_f9ab22689b95</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4225556%2F1616a917-316c-460d-b73c-7c8016f06529.jpg</url>
      <title>DEV Community: shaoor ahmad</title>
      <link>https://dev.to/shaoor_ahmad_f9ab22689b95</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shaoor_ahmad_f9ab22689b95"/>
    <language>en</language>
    <item>
      <title>Field sales automation (SFA) app source code with a one-time licence: beat plan, order booking and cash/cheque payment collection (2026)</title>
      <dc:creator>shaoor ahmad</dc:creator>
      <pubDate>Wed, 04 Nov 2026 08:37:22 +0000</pubDate>
      <link>https://dev.to/shaoor_ahmad_f9ab22689b95/field-sales-automation-sfa-app-source-code-with-a-one-time-licence-beat-plan-order-booking-and-e36</link>
      <guid>https://dev.to/shaoor_ahmad_f9ab22689b95/field-sales-automation-sfa-app-source-code-with-a-one-time-licence-beat-plan-order-booking-and-e36</guid>
      <description>&lt;p&gt;Yes: you can buy a field sales automation (SFA) app's source code once, with no per-user fee, and still get beat plans, dealer-wise order booking and cash, cheque and bank payment collection. Below is what to look for, what it costs, and one production example.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short answer
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What you pay&lt;/th&gt;
&lt;th&gt;Beat plan&lt;/th&gt;
&lt;th&gt;Order booking&lt;/th&gt;
&lt;th&gt;Cash/cheque collection&lt;/th&gt;
&lt;th&gt;Source code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Per-user SFA SaaS (BeatRoute, Bizom, FieldAssist, Delta Sales App)&lt;/td&gt;
&lt;td&gt;Per user per month, every year&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Varies&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open source ERP + mobile add-on (ERPNext, Odoo Community)&lt;/td&gt;
&lt;td&gt;Free licence + implementation&lt;/td&gt;
&lt;td&gt;Via add-ons&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Via add-ons&lt;/td&gt;
&lt;td&gt;Yes (GPL/LGPL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CodeCanyon-style script&lt;/td&gt;
&lt;td&gt;Tens to low hundreds of US$&lt;/td&gt;
&lt;td&gt;Sometimes&lt;/td&gt;
&lt;td&gt;Usually&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;td&gt;Yes, template quality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Production SFA app sold as source (Beatplan)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;US$2,900 once&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes, validated by accounts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes, full source&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Checklist for a one-time licence SFA app
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Beat plans / daily routes&lt;/strong&gt; set by team leads, visible to reps as "today's visits".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPS-verified check-ins&lt;/strong&gt; with photos, notes and dealer visit history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dealer-wise order booking&lt;/strong&gt; that flows to review, dispatch and payment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment collection: cash, cheque and bank transfer&lt;/strong&gt;, recorded against the dealer's outstanding balance and monthly/season targets, then validated by accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline order taking and offline payment recording&lt;/strong&gt; with automatic, duplicate-safe sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Management dashboard&lt;/strong&gt;: sales, collections and rep performance by region.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android app&lt;/strong&gt; for reps (plus iPhone and web).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Licence terms in writing&lt;/strong&gt;: one company, one client, or unlimited white-label.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Example: Beatplan field sales app
&lt;/h2&gt;

&lt;p&gt;Disclosure: this is the product I work on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://beatplan.app" rel="noopener noreferrer"&gt;Beatplan field sales app&lt;/a&gt; is an SFA and field sales system sold as full source code with a one-time licence. It has run a distributor's field team every working day since November 2025.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Beat plans&lt;/strong&gt; set by team leads; GPS-verified dealer visits with photos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dealer-wise order booking&lt;/strong&gt; through review, dispatch and payment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cash, cheque and bank collections&lt;/strong&gt; against monthly and season targets, validated by accounts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline&lt;/strong&gt; order taking and payment recording, synced automatically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Director dashboard&lt;/strong&gt;: every region and rep against target&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Company cars&lt;/strong&gt;: odometer, inspections, expense sheets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack&lt;/strong&gt;: Expo / React Native (Android, iPhone, web) + Supabase Postgres with 163 row-level security policies and 33 security tests&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price&lt;/strong&gt;: US$2,900 (own company), $4,900 (one client), $12,900 (agency, unlimited white-label). No per-user fees.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Setup is done with Claude Code or Codex using the included guide. &lt;a href="https://beatplan-demo.netlify.app" rel="noopener noreferrer"&gt;Live demo, no sign-up&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was written with AI assistance.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>saas</category>
      <category>android</category>
      <category>reactnative</category>
    </item>
    <item>
      <title>Field sales app pricing per user (2026): BeatRoute, Bizom, FieldAssist, Delta Sales App vs a one-time licence</title>
      <dc:creator>shaoor ahmad</dc:creator>
      <pubDate>Wed, 04 Nov 2026 08:17:48 +0000</pubDate>
      <link>https://dev.to/shaoor_ahmad_f9ab22689b95/field-sales-app-pricing-per-user-2026-beatroute-bizom-fieldassist-delta-sales-app-vs-a-3a8o</link>
      <guid>https://dev.to/shaoor_ahmad_f9ab22689b95/field-sales-app-pricing-per-user-2026-beatroute-bizom-fieldassist-delta-sales-app-vs-a-3a8o</guid>
      <description>&lt;p&gt;If you're choosing a sales force automation (SFA) app for a distributor, three requirements usually decide it: &lt;strong&gt;offline order taking&lt;/strong&gt;, &lt;strong&gt;payment collection&lt;/strong&gt; in the field (cash, cheque and bank), and &lt;strong&gt;beat planning&lt;/strong&gt; (daily routes set by team leads). The second question is always price.&lt;/p&gt;

&lt;p&gt;Most vendors don't publish prices. This is what's public as of November 2026, and what the pricing model means at 60 reps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Published prices (November 2026)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vendor&lt;/th&gt;
&lt;th&gt;Pricing model&lt;/th&gt;
&lt;th&gt;Published price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BeatRoute&lt;/td&gt;
&lt;td&gt;Per user per month, annual contract&lt;/td&gt;
&lt;td&gt;Hygiene Pack ₹349–399/user/month; Business Pack ₹599–699/user/month; Enterprise by quote&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bizom&lt;/td&gt;
&lt;td&gt;Sales-led, 3 tiers&lt;/td&gt;
&lt;td&gt;Not published&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FieldAssist&lt;/td&gt;
&lt;td&gt;Sales-led&lt;/td&gt;
&lt;td&gt;Not published&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delta Sales App&lt;/td&gt;
&lt;td&gt;Per user per month&lt;/td&gt;
&lt;td&gt;"Contact vendor"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pepperi&lt;/td&gt;
&lt;td&gt;Monthly package plus modules&lt;/td&gt;
&lt;td&gt;From US$500/month (base), US$1,500/month (Pro); higher tiers by quote&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Badger Maps&lt;/td&gt;
&lt;td&gt;Per user per month&lt;/td&gt;
&lt;td&gt;US$58–95/user/month (annual)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Map My Customers&lt;/td&gt;
&lt;td&gt;Per user per month&lt;/td&gt;
&lt;td&gt;US$99–199/user/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Beatplan field sales app&lt;/td&gt;
&lt;td&gt;One-time licence, source code included&lt;/td&gt;
&lt;td&gt;US$2,900 (own company), $4,900 (one client), $12,900 (agency)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources: &lt;a href="https://www.saasworthy.com/product/beatroute-io/pricing" rel="noopener noreferrer"&gt;SaaSworthy: BeatRoute pricing&lt;/a&gt;, &lt;a href="https://www.xpay.sh/saas-pricing/bizom" rel="noopener noreferrer"&gt;xpay: Bizom pricing&lt;/a&gt;, &lt;a href="https://www.g2.com/products/deltasalesapp/pricing" rel="noopener noreferrer"&gt;G2: Delta Sales App pricing&lt;/a&gt;, &lt;a href="https://pepperi.com/pricing" rel="noopener noreferrer"&gt;Pepperi pricing&lt;/a&gt;, &lt;a href="https://www.upperinc.com/blog/badger-maps-pricing" rel="noopener noreferrer"&gt;Upper: Badger Maps pricing&lt;/a&gt;, &lt;a href="https://toolradar.com/tools/map-my-customers/pricing" rel="noopener noreferrer"&gt;Toolradar: Map My Customers pricing&lt;/a&gt;. Prices change; confirm with each vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that means at 60 reps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BeatRoute Hygiene Pack, annual:&lt;/strong&gt; 60 × ₹349 × 12 = &lt;strong&gt;₹251,280 a year&lt;/strong&gt;. Business Pack: 60 × ₹599 × 12 = &lt;strong&gt;₹431,280 a year&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Badger Maps Business, annual:&lt;/strong&gt; 60 × $58 × 12 = &lt;strong&gt;$41,760 a year&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map My Customers Industry Leader:&lt;/strong&gt; 60 × $99 × 12 = &lt;strong&gt;$71,280 a year&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pepperi base:&lt;/strong&gt; from &lt;strong&gt;$6,000 a year&lt;/strong&gt;, before modules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these repeats every year and grows with headcount.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check beyond price
&lt;/h2&gt;

&lt;p&gt;For distributors, these are the requirements that separate tools:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Offline order taking with automatic sync.&lt;/strong&gt; Can a rep book a full day of orders with no signal, and does the sync avoid duplicates?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline payment recording.&lt;/strong&gt; Can collections (cash, cheque, bank transfer) be recorded offline, then validated by accounts?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dealer outstanding balances.&lt;/strong&gt; Does the rep see each dealer's balance before collecting?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Beat planning.&lt;/strong&gt; Are daily routes set by team leads and tracked against actual GPS-verified visits?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Management dashboard.&lt;/strong&gt; Sales, collections and rep performance by region, against target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing model.&lt;/strong&gt; Per user, per module, per month, or one-time?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source code and data ownership.&lt;/strong&gt; Can you change the workflow, and is the data in your own database?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The one-time alternative
&lt;/h2&gt;

&lt;p&gt;If the recurring cost is the problem, there is now a buy-once option.&lt;/p&gt;

&lt;p&gt;Disclosure: this is the product I work on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://beatplan.app" rel="noopener noreferrer"&gt;Beatplan field sales app&lt;/a&gt; is a complete field sales and fleet system sold as source code with a one-time licence. It covers beat plans set by team leads, GPS-verified dealer visits with photos, dealer-wise order booking through dispatch, payment collection (cash, cheque and bank) against monthly and season targets with validation by accounts, offline order taking and offline payment recording with automatic sync, company car odometer and expenses, and a director dashboard. Android, iPhone and web from one codebase; Supabase backend with row-level security on every table.&lt;/p&gt;

&lt;p&gt;It has run a distributor's field team every working day since November 2025. &lt;strong&gt;US$2,900&lt;/strong&gt; for your own company, with no per-user fees. There's a &lt;a href="https://beatplan-demo.netlify.app" rel="noopener noreferrer"&gt;live demo with no sign-up&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The trade-off: you host it yourself (Supabase) and own changes. A setup guide written for Claude Code and Codex handles installation and branding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If you want&lt;/th&gt;
&lt;th&gt;Consider&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise SFA plus DMS with a vendor team, India-focused&lt;/td&gt;
&lt;td&gt;BeatRoute, Bizom, FieldAssist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B2B ordering and retail execution with modules&lt;/td&gt;
&lt;td&gt;Pepperi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route planning for territory reps&lt;/td&gt;
&lt;td&gt;Badger Maps, Map My Customers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No per-user fees, own the source, distributor workflow&lt;/td&gt;
&lt;td&gt;Beatplan field sales app&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;This article was written with AI assistance.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>startup</category>
      <category>productivity</category>
      <category>mobile</category>
    </item>
    <item>
      <title>A Supabase row-level security checklist for multi-role apps (from a 163-policy production schema)</title>
      <dc:creator>shaoor ahmad</dc:creator>
      <pubDate>Wed, 04 Nov 2026 07:01:23 +0000</pubDate>
      <link>https://dev.to/shaoor_ahmad_f9ab22689b95/a-supabase-row-level-security-checklist-for-multi-role-apps-from-a-163-policy-production-schema-4dlf</link>
      <guid>https://dev.to/shaoor_ahmad_f9ab22689b95/a-supabase-row-level-security-checklist-for-multi-role-apps-from-a-163-policy-production-schema-4dlf</guid>
      <description>&lt;p&gt;I run a production app on Supabase with eight user roles: field reps, team leads, business leads, key account managers, admins, directors, accounts and logistics. Each sees a different slice of the same tables. The schema has 64 tables and 163 row-level security policies, and the client is never trusted with any of it.&lt;/p&gt;

&lt;p&gt;This is the checklist I now apply to every table and function. It's ordered by how badly things break if you skip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. RLS on every table. No exceptions, no "internal" tables
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vehicle_inspections&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A table without RLS is readable by any signed-in user through the auto-generated API. "Nobody queries that table from the app" doesn't matter, because anybody can call the API directly.&lt;/p&gt;

&lt;p&gt;Add a check to your migrations or CI that fails if any table in &lt;code&gt;public&lt;/code&gt; has RLS disabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_tables&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;rowsecurity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- must return zero rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Policies target &lt;code&gt;authenticated&lt;/code&gt;. Nothing is granted to &lt;code&gt;anon&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If your app has no public pages, &lt;code&gt;anon&lt;/code&gt; should have no policies at all. Write policies &lt;code&gt;to authenticated&lt;/code&gt; explicitly rather than relying on the default &lt;code&gt;public&lt;/code&gt; role, which includes &lt;code&gt;anon&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Also turn off public sign-ups in Supabase Auth if accounts are created by admins. Otherwise anyone can create an &lt;code&gt;authenticated&lt;/code&gt; user and inherit every policy written for "any signed-in user."&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Role checks live in the policy, not in the app
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"accounts validate payments"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;
          &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ACCOUNTS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'ADMIN'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hiding the "Validate" button from field reps is good UX. It isn't security. The policy is.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Users must not be able to change their own scope
&lt;/h2&gt;

&lt;p&gt;This is the most common hole I see. If your policies read &lt;code&gt;users.role&lt;/code&gt; or &lt;code&gt;users.region_label&lt;/code&gt;, and users can update their own row, a rep can promote themselves to director with one API call.&lt;/p&gt;

&lt;p&gt;Lock it down with column grants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then role and scope changes go through an admin-only function.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. &lt;code&gt;SECURITY DEFINER&lt;/code&gt; functions do their own authorisation
&lt;/h2&gt;

&lt;p&gt;Definer functions run with the owner's rights and bypass RLS. That's why you use them (aggregates across regions, dashboards), and also why they're dangerous.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;region_summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_region&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
&lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;          &lt;span class="c1"&gt;-- stops search_path hijacking&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;begin&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                 &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'TL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'BL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'ADMIN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'DIRECTOR'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'not allowed'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules apply every time: pin &lt;code&gt;search_path&lt;/code&gt;, and check the caller's role inside the function body.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Keep secrets out of tables users can read
&lt;/h2&gt;

&lt;p&gt;Our push-notification webhook shares a secret with an Edge Function. It sits in an &lt;code&gt;app_settings&lt;/code&gt; table, but the policy for that table excludes the secret row from normal users. Assume any row a user &lt;em&gt;can&lt;/em&gt; select will eventually be selected.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Never put the &lt;code&gt;service_role&lt;/code&gt; key in the client
&lt;/h2&gt;

&lt;p&gt;That one key bypasses every policy above. It belongs in Edge Function secrets and nowhere else: not in &lt;code&gt;.env&lt;/code&gt; files that ship with the app, not in a config file "just for testing."&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Test permissions as each role, in SQL, on every change
&lt;/h2&gt;

&lt;p&gt;The checklist only works if it stays true. I keep a SQL test file that impersonates each role and tries the forbidden things:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- as a field rep, try to read another region's payments&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{"sub": "&amp;lt;rep-uuid&amp;gt;"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;region_label&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'North'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- expect 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A script spins up a temporary Postgres, applies the full schema, runs a functional smoke test and the security suite (33 checks today), and prints &lt;code&gt;ALL SECURITY CHECKS PASSED&lt;/code&gt;. Any migration that adds a permission rule adds a test in the same commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RLS enabled on every table&lt;/td&gt;
&lt;td&gt;Missing RLS = readable by any signed-in user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policies &lt;code&gt;to authenticated&lt;/code&gt;, nothing to &lt;code&gt;anon&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Default role includes anonymous users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public sign-ups off when admins create accounts&lt;/td&gt;
&lt;td&gt;Otherwise anyone becomes &lt;code&gt;authenticated&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Role checks inside policies&lt;/td&gt;
&lt;td&gt;UI hiding is cosmetic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Column grants on &lt;code&gt;users&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Stops self-promotion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Definer functions pin &lt;code&gt;search_path&lt;/code&gt; and check role&lt;/td&gt;
&lt;td&gt;They bypass RLS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets excluded from readable rows&lt;/td&gt;
&lt;td&gt;Readable means read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;service_role&lt;/code&gt; never in the client&lt;/td&gt;
&lt;td&gt;It bypasses everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL tests per role, every change&lt;/td&gt;
&lt;td&gt;Permissions decay silently&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;This schema is part of &lt;strong&gt;&lt;a href="https://beatplan.app" rel="noopener noreferrer"&gt;Beatplan field sales app&lt;/a&gt;&lt;/strong&gt;, a field sales system I now license as full source code. Buyers get the migrations, all 163 policies and the security test suite, so they can verify everything above themselves before they trust it with their team's data.&lt;/p&gt;

&lt;blockquote&gt;

&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Offline-first field sales app architecture with Expo and Supabase: a production case study (sync, RLS, roles)</title>
      <dc:creator>shaoor ahmad</dc:creator>
      <pubDate>Wed, 04 Nov 2026 06:59:11 +0000</pubDate>
      <link>https://dev.to/shaoor_ahmad_f9ab22689b95/an-offline-first-field-sales-app-on-expo-and-supabase-what-a-year-of-daily-production-use-taught-me-40jd</link>
      <guid>https://dev.to/shaoor_ahmad_f9ab22689b95/an-offline-first-field-sales-app-on-expo-and-supabase-what-a-year-of-daily-production-use-taught-me-40jd</guid>
      <description>&lt;p&gt;&lt;strong&gt;Case study:&lt;/strong&gt; the architecture of an offline-first field sales app for distributor reps, built with Expo and Supabase and in production since November 2025: offline sync queue, row-level security, roles, dates and scheduled jobs.&lt;/p&gt;

&lt;p&gt;For the past year I've built and run the field sales system for a distributor. Every working day since November 2025, reps open it on mid-range Android phones, follow a planned list of dealer visits, check in with GPS and photos, record payments, take orders, and log their company car's odometer. Team leads plan the days, accounts staff validate payments, and directors watch every region against target.&lt;/p&gt;

&lt;p&gt;It's an Expo (React Native) app with a Supabase backend: 64 tables, 132 database functions, 163 row-level security policies, about 55,000 lines across 86 screens. One codebase ships to iPhone, Android and a browser console.&lt;/p&gt;

&lt;p&gt;Here's what mattered most, in the order I'd tell a team starting the same build.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Offline isn't a feature, it's the default state
&lt;/h2&gt;

&lt;p&gt;Field reps work in basements, rural roads and warehouses. If a write fails because the signal dropped, the rep doesn't retry. They move on, and the visit or payment is simply lost.&lt;/p&gt;

&lt;p&gt;So every write on a field screen goes through a small queue instead of calling Supabase directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// simplified&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;v4&lt;/span&gt; &lt;span class="nx"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;uuidv4&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;uuid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;insertOrQueue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./lib/offlineQueue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;insertOrQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visits&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;uuidv4&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;            &lt;span class="c1"&gt;// client-generated, so a replay can't duplicate the row&lt;/span&gt;
  &lt;span class="nx"&gt;dealer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;checked_in_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the network is up, the insert happens now. If not, it's stored on the device and replayed when the connection comes back. An offline banner tells the rep their work is safe.&lt;/p&gt;

&lt;p&gt;The rule that made it reliable: &lt;strong&gt;the client generates the primary key.&lt;/strong&gt; A replay that fires twice (app killed mid-sync, flaky network, double tap) hits the same &lt;code&gt;id&lt;/code&gt; and is rejected as a duplicate instead of creating a second payment. Idempotency by construction, not by hope.&lt;/p&gt;

&lt;p&gt;The same pattern wraps RPC calls (&lt;code&gt;rpcOrQueue&lt;/code&gt;) for anything that's a function rather than a plain insert.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Hiding a tab is cosmetic. The database is the security boundary
&lt;/h2&gt;

&lt;p&gt;The app has eight roles: field rep, team lead, business lead, key account manager, admin, director, accounts and logistics. Each sees a different slice. It's tempting to enforce that in the UI by hiding tabs and filtering queries.&lt;/p&gt;

&lt;p&gt;That only decides what people &lt;em&gt;see&lt;/em&gt;. Anyone with the anon key and a valid session can call the API directly. So every rule lives in Postgres:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- simplified&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"team leads read visits in their region"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;visits&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'TL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'BL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ADMIN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'DIRECTOR'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ADMIN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'DIRECTOR'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;region_label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;visits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;region_label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules I wouldn't ship without:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Every table has RLS enabled, with explicit policies for &lt;code&gt;authenticated&lt;/code&gt;. Nothing is ever granted to &lt;code&gt;anon&lt;/code&gt;.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SECURITY DEFINER&lt;/code&gt; functions set &lt;code&gt;search_path = public&lt;/code&gt; and check the caller's role themselves.&lt;/strong&gt; A definer function runs with the owner's rights, so it has to do its own authorisation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Users can't edit their own scope.&lt;/strong&gt; If a rep can update their own &lt;code&gt;role&lt;/code&gt; or &lt;code&gt;region_label&lt;/code&gt;, every policy above is worthless. Column-level grants allow only &lt;code&gt;full_name&lt;/code&gt; and &lt;code&gt;phone&lt;/code&gt; to be self-edited.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I keep a SQL test suite (33 checks) that signs in as each role and tries the things they shouldn't be able to do. It runs against a throwaway Postgres in one command and must end with &lt;code&gt;ALL SECURITY CHECKS PASSED&lt;/code&gt;. Every new permission rule gets a new test.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Dates: &lt;code&gt;toISOString().slice(0, 10)&lt;/code&gt; is a bug in half the world
&lt;/h2&gt;

&lt;p&gt;The single most common bug in a sales app is "today" being wrong.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;new Date().toISOString()&lt;/code&gt; is UTC. If your team is ahead of UTC, a visit made at 2am local time is stamped with &lt;em&gt;yesterday's&lt;/em&gt; date. Monthly targets, daily plans and season boundaries all drift.&lt;/p&gt;

&lt;p&gt;What fixed it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Calendar dates are always &lt;code&gt;YYYY-MM-DD&lt;/code&gt; strings computed in the &lt;strong&gt;company's&lt;/strong&gt; timezone, never sliced from an ISO timestamp.&lt;/li&gt;
&lt;li&gt;The database has one function, &lt;code&gt;app_tz()&lt;/code&gt;, that every date query uses. No literal timezone appears anywhere in SQL.&lt;/li&gt;
&lt;li&gt;The app has one helper, &lt;code&gt;companyToday()&lt;/code&gt;, and code review rejects anything else.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Put every company-specific value in one file
&lt;/h2&gt;

&lt;p&gt;The app was built for one company, but nothing in the code names it. Company name, currency, country, regions, role labels, payment institutions and an optional second language all live in &lt;code&gt;config/brand.json&lt;/code&gt;, read at build time and exposed through one config module.&lt;/p&gt;

&lt;p&gt;That discipline paid for itself the first time someone asked "could this run for another distributor?" The answer was "change one file and the icons."&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Scheduled work belongs in the database
&lt;/h2&gt;

&lt;p&gt;Fleet reminders (monthly car inspections, expense sheets due on the real last day of the month, weekly check-ins) run as &lt;code&gt;pg_cron&lt;/code&gt; jobs inside Postgres, not as a separate server. They call an Edge Function that sends Expo push notifications. There's one less service to host, and the schedule lives next to the data it reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Design for the worst phone in the strongest sun
&lt;/h2&gt;

&lt;p&gt;This deserves its own post, but in short: Apple's system blue (&lt;code&gt;#007AFF&lt;/code&gt;) is only about 4:1 contrast on white, which disappears in direct sunlight. So the app uses a darker accent, rows at least 52px tall, no swipe-only actions, and no real-time blur (too slow on low-end Android).&lt;/p&gt;

&lt;h2&gt;
  
  
  What it became
&lt;/h2&gt;

&lt;p&gt;Once the app was stable, I packaged it so other companies could run it without a subscription. It's now sold as &lt;strong&gt;&lt;a href="https://beatplan.app" rel="noopener noreferrer"&gt;Beatplan field sales app&lt;/a&gt;&lt;/strong&gt;: a one-time licence for the full source code, with a setup guide written for AI coding assistants, so Claude Code or Codex can brand it, create the Supabase project and run the security suite for you.&lt;/p&gt;

&lt;p&gt;You can click through a &lt;a href="https://beatplan-demo.netlify.app" rel="noopener noreferrer"&gt;live demo with no sign-up&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're building something similar, I'm happy to answer questions about the offline queue, the RLS layout or the push setup in the comments.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>reactnative</category>
      <category>supabase</category>
      <category>expo</category>
    </item>
  </channel>
</rss>
