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: enrichment/scraping source URL used by pull actions to fetch/update business details (deals, hours, images, and related extracted fields)website_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 valid for discovery enrichment - if
source_urlis not usable, enrichment falls back towebsite_urlwhenwebsite_urlis usable - imported values only fill whichever role is blank in the snapshot; they do not collapse both fields into one value
Yelp exception:
- if there is no usable first-party
website_url, a Yelp business profilesource_url(for example/biz/...) can be used as the enrichment source - if a usable first-party
website_urlexists, that first-party website is still preferred over Yelp for enrichment
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, provider usage windows, 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.
For current TestFlight testing, I am intentionally shipping a built-in mobile seed dataset so first installs can browse business data without requiring a hosted backend.
- The seed file is bundled in the app at
mobile/assets/seeds/places.json. - The mobile app attempts live backend requests first, then falls back to bundled seed data if the backend is unavailable.
- This is temporary testing behavior and is not the final production architecture.
Before production go-live, these seed-data fallback changes must be removed so all clients use the hosted backend as the single source of truth.
Production cleanup checklist:
- remove fallback imports/usages in
mobile/App.tsx - remove helper module
mobile/src/seededPlaces.ts - remove bundled seed file
mobile/assets/seeds/places.json - confirm mobile app only reads listing data from hosted API endpoints
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 - automatic cleanup of stale daily
tomtom_placesprovider usage rows in admin - 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 geocoding
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.
The backend already exposes a lightweight health endpoint for uptime checks:
GET /api/health/- expected response:
200 {"status":"ok","service":"happyhour-backend"}
Recommended monitoring setup:
- UptimeRobot: point it at the Render backend health URL, for example
https://your-render-service.onrender.com/api/health/ - 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 is also env-driven now, but it defaults to off until the production domain is final.
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
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.
Recommended production env vars:
REDIS_URL=<your-render-redis-internal-url>- optional:
CACHE_KEY_PREFIX=happyhourapp-prod
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/discovery_exclusions.jsonis file-based and must be committed/deployed with the repobackend/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
backend/config/discovery_exclusions.json. - 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 - copies of
discovered_places.jsonanddiscovery_exclusions.jsonwhen those files exist
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 exclusions 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.
- Keep
backend/config/discovery_exclusions.jsonin source control so Render deploys it. - 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.
Uploaded business profile photos use public media storage because they are displayed on business profiles. Sensitive uploads, including business claim attachments and direct-message images, use private media storage with signed URLs.
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, but this bucket only needs public profile image types such as
image/jpeg,image/png,image/webp, andimage/heic
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 need stable public URLs, while claim documents and direct-message images should not be publicly listable or fetchable without a signed URL.
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 attachments and direct-message imagesSUPABASE_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
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:
business-claim-attachments/...business-profile-photos/...direct-message-images/...
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 --noEmitRun 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.ProviderQuotaTests places.tests.ProviderUsageWindowAdminTests places.tests.DiscoveryJsonStorageTests places.tests.HerePlacesImporterTestsRun 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.