This is my first pass at building DiningDealz, a mobile-first app for finding happy hour spots, food deals, and other discounts in Ventura, Oxnard, and Camarillo, California.
I am building this project at an entry-level skill level, with some help from GitHub Copilot along the way. My goal is to learn by actually building a real app step by step instead of overcomplicating it too early.
The main goal is to create a legit mobile app, not a website that later gets wrapped into a phone app.
The app is meant to help users find:
- restaurants
- fast food spots
- bars
- cafes
- shops
- attractions
- happy hour deals
- daily specials
- limited-time discounts
For business listing data, the backend treats source_url and website_url as different roles:
source_url: official first-party website URL used by pull actions to fetch/update business details such as deals and hourswebsite_url: public website URL shown and opened in the app business profile
When running admin pull actions (single-business pull or pull-all):
- enrichment tries to use
source_urlfirst when it is a supported first-party website URL - if
source_urlis not usable, enrichment falls back towebsite_urlwhen it is usable - Yelp, social, directory, and other blocked URLs may remain as source links, but they are never fetched for enrichment
- imported values only fill whichever role is blank in the snapshot; they do not collapse both fields into one value
For the initial launch, I am keeping the scope small on purpose and only targeting these cities in the 805 area:
- Ventura
- Oxnard
- Camarillo
This is the stack I chose for the project:
- Expo 54 / React Native 0.81 / React 19 for the mobile app
- Django 6 + Django REST Framework for the backend
- Next.js for the website
- Vercel for hosting the website later
- Render for hosting the backend later
- Render Postgres for the production database later
Right now, the backend is the part that is furthest along because I wanted the mobile app to be built against real API endpoints instead of fake UI-only data.
That said, the mobile app is no longer just a placeholder. It now has a working browse experience, auth/profile flows, and business claim screens wired to the backend.
Current backend work includes:
- Django project setup inside the
backendfolder - a
placesapp for listings, claims, memberships, and account workflow - Django admin setup so I can manage claims, memberships, deleted businesses, and snapshots through
/admin - API endpoints for health, places, place details, deals, login, signup, profile dashboard, and claim-related profile actions
- importer and service layers that normalize source records into mobile-friendly JSON
- local virtual environment and backend requirements file
- passing migrations and backend tests
The Expo / React Native app is now partially built and connected to the real backend.
Current mobile work includes:
- browse mode with both list and map views
- city filters and venue-type filters
- confirmed-deal, weekday, and verified-business filtering
- keyword search across names, venue types, cities, and addresses
- shared browse controls across list and map so the search container stays stable during mode changes
- animated list/map switching and profile-dashboard transitions
- Apple Maps-style light/dark map support on iOS with a smooth theme transition
- map result trays, selected-place preview cards, and animated marker rendering
- place detail cards with photos, deal sections, hours, phone numbers, and map previews
- login and account creation flows
- profile dashboard flow with animated transitions between auth, browse, and dashboard screens
- business claim flow with consolidated business results and per-location address selection before verification
- map marker rendering based on backend-provided or resolved coordinates
- native map boundary handling for built apps, with a JS fallback for Expo Go
- modularized screen-level mobile code so auth/profile/dashboard/detail views are no longer all inline in
mobile/App.tsx
When running the mobile app against the local backend, you can now choose network mode:
- Wi-Fi adapter:
npm run start:wifi - Ethernet adapter (LAN cable):
npm run start:ethernet - Auto-detect either adapter:
npm start
All commands should be run from mobile/ after the backend is running with python manage.py runserver 0.0.0.0:8000.
The backend currently includes data models for:
ListingSnapshotBusinessClaimBusinessMembership
This lets the project store claim and ownership workflow data without keeping a long-lived restaurant/store catalog in the database.
Legacy catalog models for Place, Deal, HappyHour, and ImportRun have been removed from the active schema.
The backend now builds listing responses from source-backed records instead of serving a long-lived Place catalog out of the database.
That means the current direction is:
- pull configured listing data from curated and discovery-oriented sources
- normalize and group them at request time through the backend service layer
- expose them through API endpoints that the mobile app consumes directly
- keep durable business edits in
ListingSnapshotwhile leaving raw discovery data source-backed
The listing APIs are built from source-backed records and normalized for the mobile app.
That currently includes:
- curated business source definitions in
backend/config/business_sources.py - discovery data stored in
backend/config/discovered_places.json - grouping and deduplication in
backend/places/services/source_listings.py - coordinate backfill for records that need geocode resolution before they can appear on the mobile map
- multi-location grouping so one business profile can expose multiple addresses inside the app
- address-quality merging so partial or duplicate records collapse into a better canonical location when possible
The current runtime goal is:
- keep
ListingSnapshotas the durable source of truth for admin-edited business data - treat
backend/config/discovered_places.jsonas generated/cache/seed discovery data, not the long-term source of truth - keep listings source-backed and normalized while durable business edits live in the database
In Postgres-backed deployments, the committed backend/config/discovered_places.json file is now treated as a seed file. If the runtime discovery file does not exist yet, the backend can bootstrap a runtime copy once. After that, normal discovery writes go to the runtime file instead of mutating the committed config/ copy.
If a business has multiple locations, I want it to show up in the app as one business profile with multiple locations inside that profile, not as separate business profiles.
Because of that, multi-location brands in backend/config/settings.py should be added with the multi_location_business(profile_name, locations) helper.
That helper automatically gives every location entry the same profile_name and shared slugified profile_slug, so future brands follow the same grouping pattern as Lure Fish House and Finney's Crafthouse.
HappyHourApp/
backend/
config/
places/
manage.py
requirements.txt
mobile/
web/
Right now, these parts are working:
- backend project structure and admin workflow
- source-backed place list and place detail APIs
- deal aggregation and location grouping
- coordinate-aware map payloads for mobile browse
- business claim and membership workflow backed by
ListingSnapshot - async search in the List of Businesses admin page without full-page refreshes
- deleted-business admin controls for restore, hard delete, and suppression through
deleted_from_business_database - curated JSON catalog migration with verified business records and non-destructive admin refreshes
- Expo mobile browse UI with list and map modes
- mobile search, city filtering, venue filtering, and map/list UX polish
- mobile auth, profile dashboard, and business claim onboarding flow
- backend tests for the source listing pipeline, API endpoints, and importer behavior
These parts are not built yet:
- a completed polished mobile app release
- Next.js website UI
- production deployment
- expanded city coverage outside the first 805 launch area
- site-specific extraction rules for every business website I want to support reliably
- a finalized production cache strategy for source fetches and static listing coordinate resolution
The backend can be hosted on Render, but the current OCR setup has an important limitation on Render's standard non-Docker Python runtime.
- The Python package
pytesseractis included inbackend/requirements.txt, but it only talks to the external Tesseract binary. - Standard Render services should be treated as managed runtimes without normal OS-level package installation during build.
- Because of that, this repo does not assume a standard Render deploy can install Tesseract with
apt-getor a similar system package command.
What this means in practice:
- business-claim document scoring still works on Render without crashing
- PDF text extraction still works through
pypdf - duplicate-file detection and filename/text heuristics still work
- image OCR for scanned or photo-based uploads falls back gracefully if the Tesseract binary is unavailable
So if the backend is deployed to a standard Render service without a Tesseract-capable runtime, claim verification becomes partially OCR-assisted instead of fully OCR-assisted.
If a future Render deployment needs full image OCR, the backend runtime will need access to the tesseract executable. The remaining options are:
- switch the backend to a Docker-based Render deployment and install Tesseract there
- bundle a compiled Linux Tesseract binary with the app and point
pytesseractto it - move image OCR to an external OCR service
Until then, the current code safely degrades instead of breaking uploads or claim review.
User-visible business profile photos, deal images, and business direct-message images are screened before they are stored or displayed. Production uses the local, MIT-licensed NudeNet 320n model bundled with the backend for automated exposed-nudity detection. Image bytes stay on the backend; no per-image moderation API is used.
The production backend should define these Render environment variables:
IMAGE_MODERATION_PROVIDER=local_nudenetIMAGE_MODERATION_BLOCK_SCORE_PERCENT=65IMAGE_MODERATION_FAIL_CLOSED=true
The backend normalizes images, caches repeated results, rejects detections at or above the configured score, and refuses new image uploads if the local model cannot run. Local development defaults to moderation disabled so tests and offline work do not need to load the model.
This local detector is automated coverage for explicit nudity, not a guarantee that every harmful image category will be recognized. Text filtering, reporting, blocking, and support review remain the fallback for threats, hate, violence, scams, and false negatives. There is no separate moderation-provider bill, although image inference uses the backend service's CPU and memory.
Business-claim PDF attachments are scanned synchronously with Cloudmersive's Advanced Scan endpoint before they are written to private storage. The API key belongs only on the backend; never place it in the mobile app or commit it to the repository.
Configure these Render environment variables on the backend service:
CLOUDMERSIVE_VIRUS_SCAN_API_KEY=<your-cloudmersive-api-key>CLOUDMERSIVE_VIRUS_SCAN_BASE_URL=https://api.cloudmersive.comCLOUDMERSIVE_VIRUS_SCAN_TIMEOUT_SECONDS=15CLOUDMERSIVE_VIRUS_SCAN_FAILURE_MODE=allow
The default failure mode accepts a PDF into private storage with a visible Provider unavailable status for mandatory staff review if Cloudmersive is unavailable. Set CLOUDMERSIVE_VIRUS_SCAN_FAILURE_MODE=block only if claim submission should fail closed during provider outages. Verification uploads default to the backend-only 3,500,000 byte limit; profile photos, deal attachments, and public media keep their existing limits.
The backend already exposes a lightweight health endpoint for uptime checks:
GET /api/health/- expected response:
200withstatus: "ok"when PostgreSQL and configured Redis are reachable - dependency failures return
503with dependency status fields but no connection strings or provider error details
Recommended monitoring setup:
- UptimeRobot: point it at the Render backend health URL, for example
https://your-render-service.onrender.com/api/health/ - Notification processor: the preferred configuration is a five-minute POST monitor for
https://your-render-service.onrender.com/api/internal/process-due-happy-hour-notifications/with anAuthorization: Bearer <secret>header. Generate a new random value of at least 32 characters, set it as Render'sHAPPY_HOUR_NOTIFICATION_SECRETenvironment variable, and update the monitor at the same time. Do not put the secret in the URL. - Existing UptimeRobot HEAD monitors may keep using
https://your-render-service.onrender.com/api/internal/process-due-happy-hour-notifications/<secret>/. That compatibility route accepts only an authenticated HEAD request, rejects query-string secrets, uses a constant-time comparison, and is rate-limited. URL secrets can still appear in provider or proxy access logs, so rotate the secret if the monitor URL was exposed; migrate to header-authenticated POST when the monitor plan allows it. - Apply migrations, including
0059_secure_profile_auth_tokens, before serving traffic. It hashes existing profile tokens and adds their expiry timestamps; the web dashboard also requires users to sign in again because old localStorage tokens are discarded. - Set
HAPPY_HOUR_NOTIFICATION_WINDOW_MINUTES=10unless a different stale-alert window is intentionally needed. The processor re-reads current deal data, sends eligible happy-hour pushes once, and skips occurrences older than the configured window. - All-day happy-hour windows use the matching location's operating-hours opening time as their notification start; legacy records with no operating-hours start fall back to their stored happy-hour start time.
- Keep the notification processor monitor separate from
/api/health/; the health endpoint stays read-only and diagnostic. - Backend Sentry: set
SENTRY_DSNin Render to capture Django runtime errors - Frontend Sentry: set
NEXT_PUBLIC_SENTRY_DSNandSENTRY_DSNin Vercel to capture browser and Next.js server errors
The backend already supports HTTPS redirect and secure cookies on Render. HSTS defaults to one year on Render and should only be disabled deliberately before the final HTTPS domain is confirmed.
Recommended Render env vars after HTTPS is confirmed on the final production domain:
DJANGO_SECURE_SSL_REDIRECT=trueDJANGO_SESSION_COOKIE_SECURE=trueDJANGO_CSRF_COOKIE_SECURE=trueDJANGO_SECURE_HSTS_SECONDS=31536000DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=trueDJANGO_SECURE_HSTS_PRELOAD=true
If you want a safer rollout first, start with:
DJANGO_SECURE_HSTS_SECONDS=3600DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=falseDJANGO_SECURE_HSTS_PRELOAD=false
Once that looks good in production, move to the one-year HSTS values above.
Optional Sentry sampling environment variables:
SENTRY_ENVIRONMENTSENTRY_RELEASESENTRY_TRACES_SAMPLE_RATESENTRY_PROFILES_SAMPLE_RATENEXT_PUBLIC_SENTRY_ENVIRONMENTNEXT_PUBLIC_SENTRY_RELEASENEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATENEXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATENEXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE
Account recovery emails open the DiningDealz mobile app through its diningdealz URL scheme. Set these backend environment variables on the production service so an older web or localhost override cannot be used:
PROFILE_USERNAME_RECOVERY_URL_BASE=diningdealz://forgot-usernamePROFILE_PASSWORD_RESET_URL_BASE=diningdealz://forgot-password
The mobile app handles the username reminder screen and the token-based password reset screen. Release the updated mobile binary before testing these links on a device; the backend can continue serving the existing browser reset endpoint for direct web requests.
Restaurant sharing is prepared to use https://link.diningdealz.com/share/place/<slug>/ as an iOS Universal Link once the app has an App Store listing. An installed DiningDealz app opens the business profile directly; otherwise the link service redirects straight to the iOS App Store. The mobile share payload does not use the public DiningDealz website.
The iOS build declares applinks:link.diningdealz.com for new shares and retains applinks:backend.diningdealz.com only so older already-sent links can still open the app. The backend serves the required association document at /.well-known/apple-app-site-association. Point link.diningdealz.com at the backend Render service before enabling profile links.
Complete these steps in order:
-
Create the public link host in Render and DNS.
- In the Render dashboard, open the backend web service, go to Settings → Custom Domains, and add
link.diningdealz.com. - Render will show the DNS target for the custom domain. At the DNS provider for
diningdealz.com, create thelinkCNAME record using that target. Do not point it at the public website deployment. - Wait for Render to verify the domain and issue HTTPS. The link host must serve the backend over HTTPS.
- In the Render dashboard, open the backend web service, go to Settings → Custom Domains, and add
-
Configure the backend environment variables in Render.
PROFILE_IOS_APP_STORE_URL=https://apps.apple.com/us/app/<app-name>/id<app-id> PROFILE_SHARE_HOST=link.diningdealz.com DININGDEALZ_IOS_TEAM_ID=V6MYG36LZ9 DININGDEALZ_IOS_BUNDLE_ID=com.ia09.diningdealzUse the real App Store app URL. Do not use an App Store search URL. Save the variables and redeploy the backend.
-
Enable the link in the mobile production configuration.
- In
mobile/eas.json, setEXPO_PUBLIC_IOS_APP_STORE_URLto the same real App Store URL. - Keep
EXPO_PUBLIC_IOS_PROFILE_LINK_BASE_URLset tohttps://link.diningdealz.com/share/place. - In
mobile/app.json, setios.infoPlist.DiningDealzIOSAppStoreURLto the same URL. - Keep the matching
DiningDealzIOSAppStoreURLvalue synchronized inmobile/ios/DiningDealz/Info.plistandmobile/ios/DiningDealz/Info-Debug.plistif the checked-in native iOS project is being built directly.
- In
-
Build and reinstall the iOS app.
cd mobile eas build --platform ios --profile productionInstall the new build after deleting the older app from the device if necessary. Universal Link entitlements are embedded in the signed app, so changing Render or DNS cannot update an already-installed build.
-
Verify the deployment before sharing.
- Open
https://link.diningdealz.com/.well-known/apple-app-site-associationand confirm it returns JSON containingV6MYG36LZ9.com.ia09.diningdealzand/share/place/*. - Open
https://link.diningdealz.com/share/place/<slug>/on a device without the app and confirm it redirects to the exact App Store app page. - Open the same profile link on a device with the newly installed app and confirm DiningDealz opens the matching business profile.
- Open
Until PROFILE_IOS_APP_STORE_URL is set, no profile URL is included in shares. The mobile build is pinned to link.diningdealz.com; changing that host requires updating the iOS associated-domain entitlement and the matching AASA document together.
At the moment PROFILE_IOS_APP_STORE_URL is intentionally unset, so shares are image-only and contain no website or store link. When the app is published, set that value, point the link.diningdealz.com DNS record at the backend Render service, and rebuild/reinstall the iOS app. Universal Link entitlements are embedded in the signed app; an already-installed build will not recognize the new link host.
The backend applies scoped DRF throttles to login, signup, verification-code, password-recovery, support, direct-message, favorite, feed-write, and profile mutation endpoints.
For a single local development process, the default in-memory cache is enough. For Render production, set REDIS_URL so throttles and cache counters are shared across workers and deploy instances.
When REDIS_URL is configured, both Django cache aliases use Redis: the default cache stores throttles and general runtime values, while the source_fetch alias stores source responses and normalized listing payloads under a separate key prefix. The listing payload cache does not rebuild on every live-location update; current coordinates are overlaid from PostgreSQL when a cached payload is read.
Recommended production env vars:
REDIS_URL=<your-render-redis-internal-url>- optional:
CACHE_KEY_PREFIX=happyhourapp-prod - optional:
SOURCE_CACHE_KEY_PREFIX=happyhourapp-prod-source
Throttle rates can be tuned without code changes:
THROTTLE_PROFILE_LOGINdefaults to10/minuteTHROTTLE_PROFILE_SIGNUPdefaults to300/hourTHROTTLE_PROFILE_EMAIL_VERIFICATIONdefaults to10/minuteTHROTTLE_PROFILE_EMAIL_VERIFICATION_RESENDdefaults to3/minuteTHROTTLE_PROFILE_PASSWORD_RECOVERYdefaults to10/hourTHROTTLE_PROFILE_SUPPORT_CONTACTdefaults to10/hourTHROTTLE_PROFILE_USER_MUTATIONdefaults to120/minuteTHROTTLE_DIRECT_MESSAGE_SENDdefaults to30/minute
If I want my admin-edited business rows to survive the move from local development to Render Postgres, I need to migrate both:
- the database schema
- the actual data currently stored in
backend/db.sqlite3
The backend now supports Postgres through either:
DATABASE_URL- standard Postgres env vars such as
PGDATABASE,PGUSER,PGPASSWORD,PGHOST, andPGPORT
For Render, the simplest production setup is usually a single DATABASE_URL from the Render Postgres service plus an optional connection lifetime override:
DATABASE_URL=<your-render-postgres-internal-database-url>- optional:
DATABASE_CONN_MAX_AGE=600
If you prefer individual env vars instead of DATABASE_URL, set:
PGDATABASE=<database-name>PGUSER=<database-user>PGPASSWORD=<database-password>PGHOST=<database-host>PGPORT=5432- optional:
PGSSLMODE=require - optional:
DATABASE_CONN_MAX_AGE=600
If none of those are set, it still falls back to local SQLite in backend/config/settings.py.
These database-backed edits will transfer if I export the SQLite data and load it into Postgres:
ListingSnapshotrows, which should be treated as the durable source of truth for admin-edited business data, including edited names, addresses, phone numbers,website_url,source_url,deal_overrides, andoperating_hour_overridesBusinessClaim,BusinessMembership, and account workflow data- deleted-business records stored in the database
- uploaded-file references stored in database fields
These need separate handling:
backend/config/discovered_places.jsonis also file-based, is not moved by a database migration, and should be treated as generated/cache/seed discovery data rather than durable production business data- local uploaded media under
backend/mediais not copied into Postgres - file-based caches do not transfer automatically
This is the most important distinction to keep straight:
ListingSnapshotrows do not get created automatically just becausebackend/config/discovered_places.jsonexists.- The first Postgres-backed deploy can bootstrap the runtime discovery JSON file from the committed seed file if the runtime file does not exist yet.
- The first Postgres-backed deploy only gets my durable business edits if I explicitly migrate/import the SQLite database data into Postgres.
- After that first bootstrap, discovery JSON writes should go to the runtime discovery file, not back into the committed repo copy.
- After the first real data migration, Postgres and
ListingSnapshotshould be treated as the authoritative source of truth for admin-edited business data.
- Finish the admin edits locally.
- Commit any file-based changes that matter in production, especially the current
backend/config/discovered_places.jsonseed catalog. - Refresh or review the committed
backend/config/discovered_places.jsonseed file if I want a clean first-bootstrap discovery snapshot. - Set the production Postgres environment variables for the backend service.
- Create the Render Postgres database and note the internal/external connection string.
- Take a final SQLite export from the local app.
- Load that export into Postgres.
- Point the Render backend service at Postgres.
- Run a quick verification pass in admin and the API.
For an all-in-one local safety backup before running any admin pull, create a timestamped bundle from the backend folder:
venv\Scripts\Activate
python manage.py backup_admin_dataThat command creates backend/backups/admin-backup-YYYYMMDD-HHMMSS/ with:
- a raw SQLite copy as
db.sqlite3 - a portable Django fixture as
database-fixture.json - a
listing-snapshots.jsonexport that includes eachListingSnapshotrow, its admin-managed display fields, and any matching stored discovery record fromdiscovered_places.json - a copy of
discovered_places.jsonwhen it exists
If the admin data gets wiped locally, restoring the copied db.sqlite3 is the fastest full recovery path.
From the backend folder, after all local edits are complete:
venv\Scripts\Activate
python manage.py dumpdata --exclude auth.permission --exclude contenttypes --exclude sessions --indent 2 > data-migration.jsonWhy exclude those tables:
contenttypesandauth.permissionare recreated by migrationssessionsare temporary and not worth migrating
This fixture should contain the business/admin data that matters, including ListingSnapshot edits.
In Render:
- Create the Postgres database.
- Create or update the backend web service.
- Set either
DATABASE_URLor the equivalent Postgres env vars on the backend service. - Make sure the app can connect to Postgres instead of SQLite.
*Note: put database_url in local env so django can connect to render's postgresql database so you can migrate and load the updated listsnapshot data (and admin) into live prod (first time)
Do not rely on python manage.py migrate alone to move the data. That only creates tables.
Once Django is configured to connect to the Render Postgres database, run:
python manage.py migrateThat creates the schema in Postgres, but the database will still be empty until the fixture is loaded.
The safest approach is usually to point a local backend shell at the Render Postgres connection temporarily, then load the fixture from the local machine.
After switching the backend environment to the Render Postgres connection, run:
python manage.py loaddata data-migration.jsonThat inserts the exported SQLite rows into Postgres using Django's models instead of hand-written SQL.
This step is what makes the first deployment accurate for my admin-edited business data. The discovery JSON seed file alone is not enough for that.
Before treating Render as the new source of truth, verify:
- edited business names still appear in admin
website_urlandsource_urledits are still present onListingSnapshot- manual deal and hours overrides still exist
- deleted businesses and curated catalog data still behave as expected
/api/places/and/api/places/<slug>/still reflect the edited snapshot data
If I want the first production runtime discovery cache to be fresher than the committed seed file, I should run a discovery refresh or admin pull flow after deployment.
For this repo, Postgres is not the whole story.
- Treat
backend/config/discovered_places.jsonas seed/cache/generated discovery data only. It can be deployed as a bootstrap snapshot for the runtime discovery file, but it should not be treated as the durable system of record for business edits. - If local uploads matter, move
backend/mediato production storage separately.
- Avoid editing admin data in both SQLite and Postgres at the same time.
- Take the final
dumpdataas close to deployment as possible. - If more local edits are made after the export, take a fresh export instead of trying to merge by hand.
- If I care about the first deployed discovery snapshot, make sure the committed
backend/config/discovered_places.jsonseed is reasonably current before the first Postgres-backed deploy. - After the Render deploy is live, treat Postgres and
ListingSnapshotas the durable source of truth for business edits. - Do not treat
backend/config/discovered_places.jsonas authoritative production data after Render is live.
The local backup_admin_data command is an application-level export. It is useful for Django fixtures and admin review, but it is not a complete PostgreSQL backup and it does not download Supabase Storage objects. Use the production command below for a recoverable bundle containing:
- a custom-format PostgreSQL dump
- every object in the public and private Supabase media buckets
- the runtime discovery file and committed discovery seed
The command writes by default to %USERPROFILE%\DiningDealzBackups, outside the repository. Keep these bundles in encrypted external storage and never commit them.
The local fixture is deliberately safer than a raw database copy: it excludes places.profileauthtoken and redacts Django password hashes, account verification/recovery tokens, and authenticator secrets. The raw SQLite copy still contains all database data and must be treated as sensitive encrypted backup material.
Removing files under backend/backups/ or backend/tmp-backups/ removes only those exported backup files from the repository working tree; it does not delete ListingSnapshot rows, discovery records, or any live production database data.
The custom admin URL is only a discovery-reduction measure. The backend now adds the following controls:
- state-changing listing pulls and deleted-business restores show a confirmation page and mutate only on CSRF-protected
POSTrequests - admin sessions have a one-hour absolute lifetime and a 30-minute idle timeout by default
- admin login attempts are throttled by source address and address/username pair; configure
REDIS_URLin production so the limiter is shared by all workers - each staff account can enable authenticator-based TOTP from the
My Admin Securitypage inside the admin; after the password login succeeds, that account is sent to a separate MFA verification screen before any admin page is shown - staff and group privilege management is restricted to Django superusers; ordinary staff cannot grant
is_staff,is_superuser, groups, or object permissions ADMIN_IP_ALLOWLISTcan restrict admin requests by CIDR. When the app is behind a reverse proxy, enforce the allowlist at the Render/edge layer because the application sees the proxy address unless trusted client-IP handling is configured- structured
ADMIN_SECURITY_EVENTrecords are written to theadmin_securitylogger for external collection; privilege changes, denied networks, MFA failures, and rate limits are marked as alerts
Apply migrations as part of the deploy (python manage.py migrate) before using the new page. To enable MFA, sign in and open My Admin Security in the Administration section. Start enrollment, scan the displayed QR code with an authenticator app, and confirm with the current six-digit code. This is stored per admin account in AccountProfile; no MFA feature flag is required in Render.
Configure these Render environment variables and run the check from the deployed service or an identically configured release shell:
DJANGO_ENV=production
ADMIN_LOGIN_MAX_ATTEMPTS=5
ADMIN_LOGIN_WINDOW_SECONDS=300
ADMIN_SESSION_AGE_SECONDS=3600
ADMIN_SESSION_IDLE_TIMEOUT_SECONDS=1800
REDIS_URL=<shared-production-redis-url>
ADMIN_IP_ALLOWLIST=<trusted-cidr>,<optional-second-cidr>
python manage.py check_admin_securityThe check warns when an active superuser has not enabled admin 2FA. If any profile API token or credential-bearing backup was exposed, revoke live profile tokens after confirming the production database target:
python manage.py revoke_profile_auth_tokens --all
python manage.py revoke_profile_auth_tokens --all --confirmThe first command is a dry run; the second deletes the selected token rows. The deleted backup files are removed from the current working tree, but they remain in existing Git history until a coordinated history rewrite is completed. Configure an external append-only log drain/SIEM for ADMIN_SECURITY_EVENT and alert on "alert":true or event_type=admin_privilege_change.
Install the PostgreSQL client tools and confirm pg_dump is available:
pg_dump --versionSet BACKUP_DATABASE_URL to the external Render Postgres URL from the database service's Connect menu. Do not use the internal URL from a local computer, and do not paste the URL into source files:
$env:BACKUP_DATABASE_URL = '<external-render-postgresql-url>'
.\backend\scripts\backup-production.ps1The script reads the existing Supabase settings from backend/.env or the process environment. The required values are SUPABASE_STORAGE_BUCKET, SUPABASE_PRIVATE_STORAGE_BUCKET, SUPABASE_STORAGE_ENDPOINT, SUPABASE_STORAGE_ACCESS_KEY, and SUPABASE_STORAGE_SECRET_KEY.
The direct Django command is also available when a different output directory is needed:
backend\venv\Scripts\python.exe backend\manage.py backup_production_data --output-dir 'D:\ProtectedBackups'Each completed bundle contains manifest.json, postgresql.dump, supabase\public-media, supabase\private-media, and the discovery directory. The manifest records object counts, file sizes, SHA-256 checksums, and Supabase object metadata. Verify the database dump after copying it:
Get-FileHash 'C:\Users\<you>\DiningDealzBackups\production-backup-YYYYMMDD-HHMMSS\postgresql.dump' -Algorithm SHA256Run this before risky migrations or bulk admin changes, and retain multiple dated copies. Free Render Postgres has no managed PITR or Render logical-export facility, so an independent pg_dump copy is required.
Pause admin edits, migrations, and other writes if possible. Do not restore over the live database as the first recovery attempt.
For recent accidental deletion or corruption on a paid Render Postgres plan:
- Open the database service's Recovery page in Render.
- Choose Restore Database under Point-in-Time Recovery.
- Select a recovery time before the incident and give the new database a separate name.
- Wait for the recovery database to become available.
- Validate its schema, account data, business claims,
ListingSnapshotedits, and API responses. - Keep the current backend connected to the original database until validation is complete.
Render documents a 3-day recovery window for Hobby and 7 days for Pro or higher. Free Postgres does not provide PITR.
If PITR is unavailable or the required point is outside its window:
- Create a new empty Render Postgres database.
- Obtain its external connection URL.
- Confirm the backup dump checksum with
Get-FileHash. - Restore the custom-format dump into the new database:
$env:TARGET_DATABASE_URL = '<external-url-for-new-empty-render-database>'
pg_restore `
--dbname="$env:TARGET_DATABASE_URL" `
--verbose `
--clean `
--if-exists `
--no-owner `
--no-privileges `
--exit-on-error `
--format=custom `
'C:\Users\<you>\DiningDealzBackups\production-backup-YYYYMMDD-HHMMSS\postgresql.dump'- Validate the recovered database with Django admin, account data, business claims,
ListingSnapshotedits, and the API. - Update the Render backend service's
DATABASE_URLto the recovered database's internal URL. - Redeploy or restart the backend and verify the health endpoint, web app, and mobile app.
PostgreSQL restores file references, not the uploaded file bytes. Restore both configured buckets:
SUPABASE_STORAGE_BUCKET: public business profile mediaSUPABASE_PRIVATE_STORAGE_BUCKET: private claim-verification documents, direct-message images, and report evidence
- Select the backup bundle that matches the database recovery point.
- Confirm the current Supabase environment variables point to the intended target project and buckets. Stop if they point to the wrong project.
- Run a media-only dry run. It verifies archive paths and checksums without uploading:
backend\venv\Scripts\python.exe backend\manage.py restore_production_data `
--backup-dir 'C:\Users\<you>\DiningDealzBackups\production-backup-YYYYMMDD-HHMMSS' `
--skip-discovery- Review the public and private object names in the output.
- Apply the media restore:
backend\venv\Scripts\python.exe backend\manage.py restore_production_data `
--backup-dir 'C:\Users\<you>\DiningDealzBackups\production-backup-YYYYMMDD-HHMMSS' `
--skip-discovery `
--apply- Verify public profile photos, private claim attachments, deal attachments, and direct-message images. Confirm signed URLs work for private objects.
The command upserts archived objects and preserves content type and related metadata. It does not delete extra objects already present in the buckets.
The backup contains the runtime discovery JSON and the committed discovery seed. Restore only these files with a discovery-only dry run first:
backend\venv\Scripts\python.exe backend\manage.py restore_production_data `
--backup-dir 'C:\Users\<you>\DiningDealzBackups\production-backup-YYYYMMDD-HHMMSS' `
--skip-media- Confirm the dry run identifies the expected
runtime_discovered_placesandseed_discovered_placestargets. - Apply the discovery restore:
backend\venv\Scripts\python.exe backend\manage.py restore_production_data `
--backup-dir 'C:\Users\<you>\DiningDealzBackups\production-backup-YYYYMMDD-HHMMSS' `
--skip-media `
--apply- Treat the runtime discovery JSON as a cache/bootstrap artifact. Durable admin business edits remain in PostgreSQL
ListingSnapshotrows.
A Render service's runtime filesystem may be replaced on deploy or restart. If the runtime file is lost after recovery, the backend can bootstrap from the committed seed file according to its configured discovery settings.
- Pause writes and identify the backup timestamp or Render PITR time to use.
- Recover PostgreSQL into a new Render database and validate it without changing the live backend.
- Restore Supabase public and private objects into the intended buckets.
- Restore discovery files, then review and deploy the source-controlled exclusions file.
- Point the backend service at the recovered database's internal URL and redeploy.
- Verify admin, account flows, business data, public images, private attachments, web behavior, and mobile behavior.
- Keep the original database and old backup bundle until the recovery has been accepted and a fresh backup has been taken.
PostgreSQL restoration does not restore Supabase file bytes, and Supabase restoration does not restore PostgreSQL rows. Complete all three recovery tracks before declaring production recovered.
Public business profile photos and deal attachments use public media storage because they are displayed on approved business profiles and deals. Business claim verification documents, direct-message images, and content-report screenshots use private media storage.
Supabase folders are object-key prefixes. The bucket determines whether an object is publicly retrievable; a folder name such as private/ inside business-media does not make those objects private. The app routes media by purpose:
| Media | Bucket | Current object-key path |
|---|---|---|
| Business profile photos | business-media (public) |
businesses/.../claims/.../profile-photos/... |
| Deal attachments | business-media (public) |
businesses/.../claims/.../deal-attachments/... |
| Claim verification documents | business-private-media (private) |
businesses/.../claims/.../verification/... |
| Direct-message images | business-private-media (private) |
direct-message-images/... |
| Content-report screenshots | business-private-media (private) |
content-reports/... |
Legacy keys are also recognized during the bucket audit: business-profile-photos/... and business-deal-attachments/... stay public; business-claim-attachments/... and direct-message-images/... belong in the private bucket. For shared businesses/... keys, the nested media folder determines the destination.
For local development, no extra setup is required and uploads still use backend/media.
Create two Supabase Storage buckets for app-managed uploads.
Public bucket settings:
- Bucket name:
business-media - Public bucket:
Yes - File size limit: set this to whatever max upload size you want enforced at the storage layer
- Allowed MIME types: optional; profile photos and deal attachments use
image/jpeg,image/png,image/webp,image/heic, and deal PDFs useapplication/pdf
Private bucket settings:
- Bucket name:
business-private-media - Public bucket:
No - File size limit: set this to whatever max upload size you want enforced at the storage layer
- Allowed MIME types: optional, but include the private file types the app accepts, such as
image/jpeg,image/png,image/webp,image/heic, andapplication/pdf
Why split buckets: public profile photos and published deal attachments need stable public URLs, while verification documents, direct-message images, and report evidence should not be retrievable without private access.
To inspect and reconcile objects already in Supabase, run the new management command from an environment with the Supabase S3 credentials configured. The command reads bucket objects directly and does not need the production database:
backend\venv\Scripts\python.exe backend\manage.py reconcile_supabase_media_bucketsThe default is a read-only dry run that reports counts by direction and leaves unclassified paths alone. After reviewing the plan and taking a storage backup, add --apply to copy each misrouted object to the intended bucket, verify the copy, then remove the source copy. It preserves object keys so existing database references continue to resolve.
In Supabase, the bucket should end up with public object URLs in this format:
https://<your-project-ref>.supabase.co/storage/v1/object/public/business-media/<path-inside-bucket>
To switch media uploads to Supabase Storage, set these backend environment variables exactly like this:
MEDIA_STORAGE_BACKEND=supabasePRIVATE_MEDIA_STORAGE_BACKEND=supabaseSUPABASE_STORAGE_BUCKET=business-mediaSUPABASE_PRIVATE_STORAGE_BUCKET=business-private-mediaSUPABASE_STORAGE_ENDPOINT=https://<your-project-ref>.supabase.co/storage/v1/s3SUPABASE_STORAGE_ACCESS_KEY=<your-supabase-s3-access-key>SUPABASE_STORAGE_SECRET_KEY=<your-supabase-s3-secret-key>SUPABASE_STORAGE_PUBLIC_URL_BASE=https://<your-project-ref>.supabase.co/storage/v1/object/public/business-media- optional:
SUPABASE_PRIVATE_STORAGE_SIGNED_URL_EXPIRE_SECONDS(defaults to3600) - optional:
SUPABASE_STORAGE_REGION(defaults tous-east-1)
If you want to set the optional region explicitly, use:
SUPABASE_STORAGE_REGION=us-east-1
SUPABASE_STORAGE_BUCKET: the exact Supabase bucket nameSUPABASE_PRIVATE_STORAGE_BUCKET: the exact private Supabase bucket name for claim-verification documents, direct-message images, and report evidenceSUPABASE_STORAGE_ENDPOINT: the S3-compatible Supabase storage endpoint, not the public object URLSUPABASE_STORAGE_ACCESS_KEY: the S3 access key from SupabaseSUPABASE_STORAGE_SECRET_KEY: the S3 secret key from SupabaseSUPABASE_STORAGE_PUBLIC_URL_BASE: the public base URL for objects inside that bucketSUPABASE_PRIVATE_STORAGE_SIGNED_URL_EXPIRE_SECONDS: how long private media URLs should remain usable after the API returns them
Supabase controls visibility at the bucket level and does not support S3 object ACLs. Keep business-media public and business-private-media private; do not configure default_acl on either S3 storage backend.
MEDIA_STORAGE_BACKEND=supabase
PRIVATE_MEDIA_STORAGE_BACKEND=supabase
SUPABASE_STORAGE_BUCKET=business-media
SUPABASE_PRIVATE_STORAGE_BUCKET=business-private-media
SUPABASE_STORAGE_ENDPOINT=https://abcd1234.supabase.co/storage/v1/s3
SUPABASE_STORAGE_ACCESS_KEY=your-s3-access-key
SUPABASE_STORAGE_SECRET_KEY=your-s3-secret-key
SUPABASE_STORAGE_PUBLIC_URL_BASE=https://abcd1234.supabase.co/storage/v1/object/public/business-media
SUPABASE_PRIVATE_STORAGE_SIGNED_URL_EXPIRE_SECONDS=3600
SUPABASE_STORAGE_REGION=us-east-1
Once Supabase is configured and enabled, app-managed uploads stored under these paths:
businesses/.../claims/.../verification/...businesses/.../claims/.../profile-photos/...businesses/.../claims/.../deal-attachments/...direct-message-images/...content-reports/...
will be deleted from storage when:
- the related
BusinessClaimAttachmentrecord is deleted - a
BusinessClaimis deleted from admin or elsewhere in the backend - uploaded profile photos are removed from a business profile and no longer referenced
- an expired direct-message image is lazily cleaned up after its 24-hour display window
This cleanup does not apply to external image URLs that were never uploaded by the backend.
The backend also now cleans up app-managed media when claim attachments are deleted, when uploaded profile photos are removed from a claim, and when an entire claim is deleted.
To remove old local orphaned media files that were left behind by earlier test accounts, run this from backend:
venv\Scripts\python.exe manage.py cleanup_orphaned_media --deleteRun it without --delete first for a dry run.
The current focus is tightening the existing mobile + backend loop instead of starting from scratch.
That mainly means:
- improving mobile browse/map polish and gesture behavior
- smoothing browse/profile transitions and map/list interaction polish
- improving source data quality and duplicate-location cleanup
- tightening claim/account flows
- expanding reliable business coverage inside Ventura, Oxnard, and Camarillo
- keeping the README and local workflow notes aligned with the actual codebase state
From the backend folder:
venv\Scripts\Activate
python manage.py migrate
python manage.py runserverOr use the helper script from the backend folder:
.\start-mobile-dev.ps1Then Django admin should be available at:
http://127.0.0.1:8000/admin/
The mobile app reads from the backend API, so the backend needs to be running while testing the Expo app locally.
From the mobile folder:
npm install
npm startOther useful mobile commands:
npm run ios
npm run android
npx tsc --noEmitiOS uses two version values:
- Marketing version: the user-facing App Store version, such as
1.0.0. Change this when preparing a new App Store release version. - Build number: the unique integer for each uploaded binary, such as
49,50, or51. Do not reuse a build number after Apple has accepted that binary.
Production EAS builds use the settings in mobile/eas.json:
appVersionSource: "remote"means EAS servers own the canonical build number.autoIncrement: truemeans EAS increments that remote number for the next production build.
For a normal EAS production build, do not manually change the build number in Xcode or mobile/app.json first. Check the remote value when needed:
cd mobile
npx eas-cli@latest build:version:getIf the local native project needs to match the value stored on EAS, synchronize it with:
npx eas-cli@latest build:version:syncAn archive uploaded directly from Xcode is outside EAS automatic incrementing. Before creating the archive, set the Xcode target's Build value, or CURRENT_PROJECT_VERSION, to the next unused number. The marketing version stays unchanged unless this is a new App Store version.
For example, if the EAS remote build number is 49 and the next upload will come from Xcode, use build 50. Do not use 49 again. After the Xcode upload succeeds, update the EAS remote value so the next EAS build starts from the correct number:
cd mobile
npx eas-cli@latest build:version:setWhen prompted, enter the build number that was just uploaded, such as 50. The next EAS production build will then increment to 51.
If an EAS build has already uploaded build 50, the next Xcode archive must use 51 instead. The same unique-number rule applies regardless of whether the binary was uploaded by EAS or Xcode.
The local App Store listing source is mobile/store.config.json. It can be validated and synchronized with App Store Connect:
cd mobile
npx eas-cli@latest metadata:lint
npx eas-cli@latest metadata:pull
npx eas-cli@latest metadata:pushmetadata:pull imports the current App Store Connect listing into the local file. metadata:push sends the local file to App Store Connect and can overwrite portal edits. Screenshots, app previews, privacy nutrition labels, age rating, pricing, and availability are still managed in App Store Connect.
Run tests:
python manage.py test placesPreview the configured source data without writing catalog rows to the database:
python manage.py import_source_data --source business_websitesRun the focused backend API tests used during recent mobile/data fixes:
python manage.py test places.tests.PlaceApiTests places.tests.BusinessWebsiteImporterTestsRun the focused admin and discovery workflow tests used during recent data/admin updates:
python manage.py test places.tests.ListingSnapshotAdminTests places.tests.DiscoveryJsonStorageTests places.tests.BusinessWebsiteImporterTestsRun a broader backend validation pass:
python manage.py check
python manage.py test placesRun and fill up or take out temporary demo feed data (Home feed for business advertisement)
python manage.py cleanup_demo_home_feed to remove demo feed data
python manage.py seed_demo_home_feed to fill it back up again
*Note: seeding businesses into the app will go into the database temporarily and will have the number businesses appear greater than what they actually are. Run the cleanup command to have the business count number return back to normal*I am intentionally trying to build this in phases:
- backend skeleton
- source-backed and discovery-backed listings
- working thin mobile app
- better extraction rules and data cleanup
- broader city expansion later
I am still learning, so I am keeping the structure practical and understandable instead of trying to make it perfect too early.
This project is mainly about building something real, learning the stack, and creating a strong mobile-first foundation.