TL;DR
The one-paragraph version before you dive in.
Paste the starter prompt into Lovable Build mode and get a staff-only inventory tracker with SKUs, multi-location stock, a movement ledger, atomic stock decrements via a Postgres RPC, and color-coded low-stock badges. Build time is roughly one day. Expected credit burn is 100–180 credits on a Pro $25/mo plan.
Setup checklist
Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.
Cloud tab settings
Database
Stores the 5-table schema (products, locations, stock, movements, low_stock_alerts) and hosts the record_movement() SECURITY DEFINER RPC that atomically keeps stock consistent.
- 1Click the + button next to Preview in the top toolbar to open the Cloud tab
- 2Click Database — you will see an empty Table Editor
- 3Leave it empty — the starter prompt creates all 5 tables, the has_role() function, the record_movement() RPC, all RLS policies, and seeds 5 products + 2 locations with opening stock
Auth
Email+password staff login. Role (admin or staff) is stored in app_metadata so it's baked into the JWT and cannot be tampered with from the client.
- 1Cloud tab → Users & Auth
- 2Confirm Email sign-in is enabled (it is by default)
- 3Toggle 'Allow new users to sign up' to OFF — invite staff manually from the Users page
- 4After the build, set each user's app_metadata to {"role": "staff"} (or "admin") from Cloud tab → Users & Auth → click the user row → Edit
Storage
Holds product images uploaded from the Products module — one private bucket with admin-only read/write.
- 1Cloud tab → Storage
- 2Click Create bucket, name it 'product-images', leave Public OFF
- 3Note the name — the starter prompt references it in the products table image_path column
Edge Functions
check-low-stock runs daily via pg_cron to email buyers; import-csv handles bulk product and stock import from spreadsheets.
- 1Cloud tab → Edge Functions
- 2No setup needed before the starter — follow-up #1 scaffolds check-low-stock and follow-up #2 scaffolds import-csv
- 3After those follow-ups, add the RESEND_API_KEY secret in Cloud tab → Secrets before deploying
Secrets (Cloud tab → Secrets)
RESEND_API_KEYPurpose: Enables the check-low-stock Edge Function to send daily low-stock alert emails to buyers
Where to get it: Sign up at resend.com → API Keys → Create API Key. Copy the key starting with re_.
Preflight checklist
- You're in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded — that's the default)
- You're on Pro $25/mo — the starter prompt alone runs ~50–70 credits, and the full chain with CSV import and transfers reaches 100–180 credits
- You understand the core pattern: stock is a DERIVED value maintained by the record_movement() RPC — you will never write directly to the stock table from client code
The starter prompt — paste this first
Copy this. Paste it into Lovable Build mode (the default chat at the bottom-left of the editor). Hit send.
Build a staff-only inventory management system. Stack: React + Vite + TypeScript + Tailwind + shadcn/ui (already scaffolded), React Router, Supabase JS client against Lovable Cloud.
## Database schema (create one migration)
Create 5 tables with RLS enabled on ALL of them:
1. `products` — id (uuid pk default gen_random_uuid()), sku (text unique not null), name (text not null), description (text), reorder_point (int not null default 0, check reorder_point >= 0), unit (text default 'each'), image_path (text, Storage path), archived (bool default false), created_at (timestamptz default now()).
2. `locations` — id (uuid pk default gen_random_uuid()), name (text not null), address (text), created_at (timestamptz default now()).
3. `stock` — id (uuid pk default gen_random_uuid()), product_id (uuid not null references products(id) on delete cascade), location_id (uuid not null references locations(id) on delete cascade), qty (int not null default 0, CHECK (qty >= 0)), updated_at (timestamptz default now()). UNIQUE(product_id, location_id).
4. `movements` — id (uuid pk default gen_random_uuid()), product_id (uuid not null references products(id)), location_id (uuid not null references locations(id)), delta (int not null), reason (text not null, check reason in ('receipt','sale','transfer_in','transfer_out','adjustment','return','damage')), reference (text), actor_id (uuid references auth.users(id)), created_at (timestamptz default now()).
5. `low_stock_alerts` — id (uuid pk default gen_random_uuid()), product_id (uuid references products(id)), location_id (uuid references locations(id)), triggered_at (timestamptz default now()), resolved_at (timestamptz), qty_at_trigger (int), reorder_point_at_trigger (int).
Create a SECURITY DEFINER plpgsql function `has_role(check_role text) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER STABLE` that reads `(auth.jwt() -> 'app_metadata' ->> 'role')` and returns whether it matches. NEVER use SQL-language — it gets inlined.
Create the critical SECURITY DEFINER plpgsql function `record_movement(p_product_id uuid, p_location_id uuid, p_delta int, p_reason text, p_reference text DEFAULT NULL) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS`:
```sql
BEGIN
-- Insert the movement row
INSERT INTO movements(product_id, location_id, delta, reason, reference, actor_id)
VALUES (p_product_id, p_location_id, p_delta, p_reason, p_reference, auth.uid());
-- Atomically UPSERT stock with row-level lock on existing row
INSERT INTO stock(product_id, location_id, qty, updated_at)
VALUES (p_product_id, p_location_id, p_delta, now())
ON CONFLICT (product_id, location_id) DO UPDATE
SET qty = stock.qty + p_delta,
updated_at = now();
-- The CHECK (qty >= 0) on the stock table rejects oversells at DB level
-- Alert if newly below reorder_point and no open alert exists
IF (SELECT qty FROM stock WHERE product_id = p_product_id AND location_id = p_location_id) <
(SELECT reorder_point FROM products WHERE id = p_product_id)
THEN
IF NOT EXISTS (
SELECT 1 FROM low_stock_alerts
WHERE product_id = p_product_id AND location_id = p_location_id AND resolved_at IS NULL
) THEN
INSERT INTO low_stock_alerts(product_id, location_id, qty_at_trigger, reorder_point_at_trigger)
SELECT p_product_id, p_location_id,
(SELECT qty FROM stock WHERE product_id = p_product_id AND location_id = p_location_id),
(SELECT reorder_point FROM products WHERE id = p_product_id);
END IF;
ELSE
-- Auto-resolve alert if back above reorder_point
UPDATE low_stock_alerts
SET resolved_at = now()
WHERE product_id = p_product_id AND location_id = p_location_id AND resolved_at IS NULL;
END IF;
END;
```
RLS policies:
- products: SELECT and UPDATE by has_role('staff') OR has_role('admin'); INSERT/DELETE by has_role('admin') only
- locations: SELECT by has_role('staff') OR has_role('admin'); INSERT/DELETE by has_role('admin')
- stock: SELECT by has_role('staff') OR has_role('admin'); NO INSERT/UPDATE/DELETE policies for authenticated users — all writes go through record_movement() RPC only
- movements: SELECT by has_role('staff') OR has_role('admin'); INSERT only via record_movement() function (no direct INSERT policy)
- low_stock_alerts: SELECT by has_role('staff') OR has_role('admin'); INSERT/UPDATE by service role only (handled inside record_movement)
Seed data: insert 5 products (different SKUs and units), 2 locations (e.g., 'Main Warehouse', 'Retail Store'), and opening stock for each product × location via calls to record_movement() with reason='receipt'.
## Layout
Create `src/layouts/AppLayout.tsx` — fixed left sidebar (220px) with nav links (Stock, Products, Locations, Movements, Alerts, Import, Settings) and a bottom user menu. Main area scrolls. Location filter pill in top-right header (defaults to 'All locations').
Create `src/components/StaffGuard.tsx` — calls supabase.auth.getUser(), reads app_metadata.role, shows a centered spinner while resolving, redirects to /unauthorized if role NOT IN ('admin','staff').
Create `src/components/AdminGuard.tsx` — same pattern but requires has_role('admin'); used for Products edit, Locations, Import, Settings.
## Pages and routes
Create these routes:
- `/stock` — current stock matrix grouped by product × location. QuantityBadge shows green if qty >= reorder_point, amber if 1 ≤ qty < reorder_point, red if qty = 0. Location filter pill at top filters the matrix. Sortable by product name or qty.
- `/products` — product catalog table (sku, name, unit, reorder_point, archived toggle). Admin-only edit via Sheet: name, sku, price (not stored — display only), reorder_point, unit, image upload (Storage bucket 'product-images', 40×40 thumbnail in table).
- `/locations` — location list + Add location form. Admin-only.
- `/movements` — chronological ledger with filter by product, location, reason, and date range. Each row shows: product name, location, delta (green positive / red negative), reason badge, reference, actor, timestamp.
- `/alerts` — open (resolved_at IS NULL) and resolved tabs. Each alert shows product, location, qty_at_trigger vs reorder_point. 'Mark resolved' button updates resolved_at directly (staff can resolve manually, even though the RPC auto-resolves).
- `/import` — CSV file picker, parse client-side with PapaParse, show preview table (CSVImportPreview with green valid / red error rows), Confirm button calls import-csv Edge Function (wired in follow-up #2). Admin-only.
- `/settings` — default location picker + alert recipient emails list. Admin-only.
- `/unauthorized` — one-line message with Sign out button.
## Components
Create:
- `src/components/QuantityBadge.tsx` — pill with emerald-100/emerald-700 (good), amber-100/amber-700 (low), red-100/red-700 (zero). Accepts qty and reorderPoint props.
- `src/components/MovementForm.tsx` — shadcn Dialog. Fields: product picker (combobox), location picker (Select), delta with +/- buttons and numeric input, reason dropdown (receipt/sale/adjustment/return/damage), reference text. On submit, calls `supabase.rpc('record_movement', {p_product_id, p_location_id, p_delta, p_reason, p_reference})`. Shows current qty for the selected product+location below the form.
- `src/components/StockTable.tsx` — shadcn Table in a 32px-row compact layout. Columns: SKU, Product Name, Location, Qty (rendered as QuantityBadge), Reorder Point, Last Movement date. Sortable headers.
- `src/components/CSVImportPreview.tsx` — renders parsed CSV rows as a table with validation error highlights (red row background + error column for invalid SKU, missing delta, invalid reason).
- `src/hooks/useRecordMovement.ts` — TanStack Query useMutation that calls record_movement RPC and invalidates the stock and movements queries on success.
## Styling
Light mode default with optional dark mode via shadcn ThemeProvider. Primary color: slate-900. Quantity badges as described above. 32px compact table rows. Product images as 40×40 square thumbnails with object-cover.
Generate the migration first (confirm it includes the full record_movement function SQL), then the layout + guards, then pages in the order listed above.What this prompt generates
- Creates a SQL migration with 5 tables, has_role() SECURITY DEFINER function, record_movement() SECURITY DEFINER RPC with atomic UPSERT + auto-alert logic, all RLS policies, and seed data
- Scaffolds AppLayout with sidebar nav, location filter, StaffGuard, and AdminGuard
- Generates 7 page components (Stock, Products, Locations, Movements, Alerts, Import, Settings) wired to React Router
- Builds MovementForm with record_movement RPC call, StockTable with QuantityBadge color coding, and CSVImportPreview validation UI
- Wires all stock mutations through the RPC — no direct client writes to the stock table are possible
Paste into: Lovable Build mode (the default chat at the bottom-left of the editor)
Expected output
What Lovable will generate after the starter prompt runs successfully.
Files
supabase/migrations/0001_inventory_schema.sql5 tables + has_role() + record_movement() SECURITY DEFINER RPC + CHECK constraint + RLS policies + seed data
src/layouts/AppLayout.tsxSidebar nav with location filter pill and user menu
src/components/StaffGuard.tsxRedirects non-staff to /unauthorized while session loads
src/components/AdminGuard.tsxRedirects non-admin away from edit/import/settings routes
src/components/StockTable.tsxCompact 32px-row table with sortable columns and QuantityBadge cells
src/components/MovementForm.tsxDialog for recording stock movements via record_movement RPC
src/components/QuantityBadge.tsxColor-coded pill: green (good) / amber (low) / red (zero)
src/components/CSVImportPreview.tsxParsed CSV preview table with per-row validation errors
src/hooks/useRecordMovement.tsTanStack Query mutation that calls record_movement RPC and invalidates stock + movements queries
src/pages/Stock.tsxStock matrix with location filter and QuantityBadge columns
src/pages/Products.tsxProduct catalog with admin edit Sheet and image upload
src/pages/Locations.tsxLocation list and add form (admin only)
src/pages/Movements.tsxChronological ledger with multi-filter controls
src/pages/Alerts.tsxOpen and resolved low-stock alerts with manual resolve button
src/pages/Import.tsxCSV file picker + CSVImportPreview + Confirm import (admin only)
src/pages/Settings.tsxDefault location + alert recipients (admin only)
Routes
Current stock levels matrix with location filter and color-coded badges
Product catalog with admin CRUD sheet
Location list and add form
Chronological ledger with filters
Low-stock alerts with manual resolve
CSV import with dry-run preview
Default location and alert recipients
Fallback for non-staff users
DB Tables
products| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| sku | text unique not null |
| name | text not null |
| reorder_point | int not null default 0 |
| unit | text default 'each' |
| image_path | text |
| archived | bool default false |
RLS: SELECT by staff/admin; INSERT/UPDATE/DELETE by admin only
locations| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| name | text not null |
| address | text |
RLS: SELECT by staff/admin; INSERT/DELETE by admin only
stock| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| product_id | uuid not null references products(id) on delete cascade |
| location_id | uuid not null references locations(id) on delete cascade |
| qty | int not null default 0 |
| updated_at | timestamptz default now() |
RLS: SELECT by staff/admin; NO INSERT/UPDATE/DELETE policies for authenticated users — writes ONLY through record_movement() SECURITY DEFINER RPC
movements| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| product_id | uuid not null references products(id) |
| location_id | uuid not null references locations(id) |
| delta | int not null |
| reason | text not null |
| reference | text |
| actor_id | uuid references auth.users(id) |
RLS: SELECT by staff/admin; INSERT only via record_movement() function — no direct INSERT policy for authenticated users
low_stock_alerts| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| product_id | uuid references products(id) |
| location_id | uuid references locations(id) |
| triggered_at | timestamptz default now() |
| resolved_at | timestamptz |
| qty_at_trigger | int |
| reorder_point_at_trigger | int |
RLS: SELECT by staff/admin; INSERT/UPDATE by service role only (record_movement handles this internally)
Components
StaffGuardSession-aware role check — spinner while loading, redirects non-staff
AdminGuardRequires admin role for edit/import/settings routes
StockTableCompact sortable table with QuantityBadge cells and location filter
MovementFormDialog that records stock movements via record_movement RPC
QuantityBadgeColor-coded pill based on qty vs reorder_point threshold
CSVImportPreviewClient-side CSV parse with per-row validation before Edge Function submit
Follow-up prompts
Paste these into Agent Mode one by one, in order, after the starter prompt finishes.
Wire low-stock email alerts via daily cron
Daily 06:00 UTC cron email listing all open low-stock alerts to the configured buyer email
Create a Supabase Edge Function at `supabase/functions/check-low-stock/index.ts` (Deno). It should:
1. Query `low_stock_alerts` WHERE resolved_at IS NULL, joined to products (sku, name, reorder_point) and locations (name).
2. Group alerts by buyer email (read from a `settings` table or a hardcoded ALERT_RECIPIENT_EMAIL secret from Deno.env.get).
3. For each recipient, send one Resend email (RESEND_API_KEY from Deno.env.get) with a table of SKU, product name, current qty, reorder point, and location name. Subject: 'Low stock alert — X items need reordering'.
4. Return a JSON summary of how many emails were sent.
Schedule it via pg_cron: in a new SQL migration, run `SELECT cron.schedule('check-low-stock-daily', '0 6 * * *', $$SELECT net.http_post(url:='<EDGE_FUNCTION_URL>/check-low-stock', headers:='{"Authorization": "Bearer " || current_setting(''app.service_role_key'')}'::jsonb)$$);`. Document that the URL must be replaced with the actual function URL from Cloud tab → Edge Functions after deploy.When to use: After 3 or more low-stock alerts have triggered in testing
Add CSV import for products and opening stock
Transactional CSV import via Edge Function with per-row validation and full rollback on any error
Create a Supabase Edge Function at `supabase/functions/import-csv/index.ts` (Deno). It should:
1. Accept a multipart/form-data POST with a CSV file field.
2. Parse the CSV with the csv-parse npm module (import via esm.sh). Expected columns: sku, name, unit, reorder_point, location_name, qty (opening stock).
3. For each row, validate: sku is present and non-empty, qty is a non-negative integer, location_name matches an existing location. Collect all errors into a per-row errors array.
4. If any row has errors, return 422 with the full per-row errors array — do not import any rows.
5. If all rows are valid, call record_movement() for each row (reason='receipt', reference='csv_import_${timestamp}') inside a transaction using supabase_functions.batch RPC pattern, wrapping all calls in a BEGIN/COMMIT block. If any single call fails, roll back ALL rows and return the error.
In the /import page, after CSVImportPreview shows green for all rows, the Confirm button POSTs to import-csv. Show a loading state during upload. On success, show 'X rows imported' toast. On error, re-render CSVImportPreview with the server-returned per-row errors.When to use: When migrating from a spreadsheet — typically the very first action after the app is built
Add stock transfer between locations
Atomic transfer between locations that inserts paired transfer_out/transfer_in movements in a single transaction
Extend record_movement() to support transfers, or create a new SECURITY DEFINER function `record_transfer(p_product_id uuid, p_from_location uuid, p_to_location uuid, p_qty int, p_reference text DEFAULT NULL) RETURNS void` that:
1. Inserts a movements row for the from-location with delta = -p_qty and reason='transfer_out'.
2. Inserts a movements row for the to-location with delta = +p_qty and reason='transfer_in'.
3. UPSERTs both stock rows atomically in a single BEGIN/COMMIT block.
4. Checks the from-location stock has enough qty before decrementing (raise exception 'Insufficient stock' if not).
Add a 'Transfer stock' button to the /stock page that opens a shadcn Dialog (TransferForm) with: product picker, from-location selector, to-location selector, qty input (max = current qty at from-location), and optional reference field. On submit, calls `supabase.rpc('record_transfer', {...})`. Invalidate stock and movements queries on success.When to use: When you operate from 2 or more locations and need to move stock between them
Add barcode scan via device camera
Phone-as-scanner for warehouse receiving — scan barcode, tap +, confirm receipt in 3 taps
Install @zxing/library for QR/barcode scanning. Add a new /movements/quick-receipt route with a BarcodeScanPage component that:
1. Renders a ZXing BarcodeReader component targeting the device camera. On scan, receives the decoded text (the SKU barcode value).
2. Fetches the product matching that SKU from Supabase.
3. If found, pre-fills a compact MovementForm with reason='receipt', delta=1, the scanned product, and the default location from settings.
4. Shows a +/- quantity button (tap + to increment before confirming) and a large 'Confirm Receipt' button.
5. On confirm, calls record_movement RPC and shows a success toast ('1 unit of Product X received'). Resets the scanner for the next scan.
Add a 'Scan' button to the /movements page that navigates to /movements/quick-receipt. On desktop, show a text input fallback since camera may not be available.When to use: When you want to use a phone or tablet for receiving goods without typing SKUs
Add stock report CSV export
CSV stock export via Edge Function with current location filter applied, for monthly accounting
Create a Supabase Edge Function at `supabase/functions/export-stock-report/index.ts` (Deno). It should:
1. Accept GET with optional query params: location_id (filter), include_archived (bool).
2. Query stock joined to products and locations. Include columns: sku, product_name, unit, location_name, qty, reorder_point, last_movement_at (from the latest movements row for that product+location).
3. Return a text/csv response with those columns and Content-Disposition: attachment; filename="stock-report-{date}.csv".
Add an 'Export CSV' button on the /stock page (top-right, shadcn Button variant outline). On click, fetch the edge function URL with current location filter, trigger a browser download via anchor with download attribute. Show loading state on the button while the function runs.When to use: When you need to send current stock levels to an accountant or submit to a buyer
Common errors
Real error strings you'll see. Find yours, paste the fix prompt.
new row violates check constraint "stock_qty_check"Someone attempted to sell or adjust stock below zero. The CHECK (qty >= 0) constraint on the stock table correctly blocked the oversell. This is the system working as designed — your UI did not prevent submitting a negative-delta that exceeds current stock.
In MovementForm, before submitting a negative delta, query current stock qty for the selected product+location: `const { data } = await supabase.from('stock').select('qty').eq('product_id', productId).eq('location_id', locationId).single();`. If `(data.qty + delta) < 0`, disable the Confirm button and show 'Only X units in stock at this location'. Do NOT remove the DB constraint — it is the last line of defense against concurrent sells that both pass the client check.permission denied for table stock (when trying to add a receipt)Your MovementForm is calling supabase.from('stock').upsert(...) directly. The stock table intentionally has no INSERT/UPDATE policy for authenticated users — all writes must go through the record_movement() RPC.
Replace all direct `supabase.from('stock').*` write calls with `supabase.rpc('record_movement', {p_product_id, p_location_id, p_delta, p_reason, p_reference})`. The RPC is SECURITY DEFINER so it bypasses RLS internally while still being callable only by authenticated users. Check the MovementForm's submit handler and the useRecordMovement hook — both must use rpc(), not from().Two users sell the last unit at the same time — one gets a 500 errorWithout the atomic record_movement() RPC, both clients read qty=1, compute new qty=0, and call UPDATE simultaneously. One succeeds; the other hits the CHECK constraint and gets a 42P01 error that surfaces as a 500 in the UI.
This is correct behavior — the constraint correctly rejected the duplicate sell. Surface the error cleanly: in useRecordMovement's onError handler, check if the PostgreSQL error code is '23514' (check_violation) and show a user-friendly toast: 'Out of stock — this item was sold by someone else at the same moment'. Do not remove the DB constraint.
CSV import partially succeeds and leaves stock in an inconsistent stateThe import-csv Edge Function processed rows sequentially without a transaction wrapper. Rows 1–49 inserted movements successfully; row 50 failed (invalid SKU), but rows 1–49 were already committed.
In import-csv Edge Function, wrap the full import loop in a Supabase RPC that handles all rows as a batch with a BEGIN/COMMIT block and RAISE EXCEPTION on any per-row error. Create a `import_movements_batch(p_rows jsonb) RETURNS jsonb` SECURITY DEFINER function that iterates the JSON array, calls record_movement for each row inside a single transaction, and raises an exception with a per-row error report if any row fails. If any row fails, Postgres rolls back all preceding inserts automatically.
Low-stock alerts fire for the same SKU every time the daily cron runsThe check-low-stock Edge Function is inserting a new low_stock_alerts row on every run instead of checking for an existing unresolved alert for the same product+location.
This means the record_movement() function's de-duplication check isn't working. Verify the function includes: `IF NOT EXISTS (SELECT 1 FROM low_stock_alerts WHERE product_id = p_product_id AND location_id = p_location_id AND resolved_at IS NULL) THEN INSERT ...`. If the function was generated without this check, add a new migration that replaces record_movement() with the corrected version from the starter prompt.
Cost reality
What this build actually costs — no surprises on your card.
Recommended Lovable plan
Pro $25/mo recommended — the record_movement RPC and CHECK constraint pattern usually work on the first try; most iteration credits go into UI polish (StockTable column sort, location filter, badge styling). The full starter typically fits within the Pro allowance.
Monthly run cost breakdown
~100–180 credits estimated (starter ~50–70, follow-ups 1–3 ~170 if all done; most ship after follow-up #3 with CSV import and transfers). Note: this is a cousin-substitution category with no exactly documented Lovable inventory build — estimates are extrapolated from admin-panel and Edge Function patterns. total| Item | Cost |
|---|---|
| Lovable Pro Can drop to Free after launch — the app continues to run on Cloud | $25/mo while iterating |
| Supabase / Lovable Cloud 500MB DB handles ~5M movements rows; internal-tool traffic is light | $0/mo at MVP scale |
| Resend Daily low-stock alert digests — 3K/mo is generous for an internal alert tool | $0/mo up to 3K emails |
| Custom domain Point inventory.yourcompany.com at the published app | ~$10–15/yr |
Scaling notes: The 500MB DB Free cap allows roughly 5 million movements rows. A busy warehouse writing 20 movements/day per 100 SKUs generates about 700K rows/year — the Free tier covers 7+ years before you need Supabase Pro $25/mo. The more likely ceiling is the Edge Function timeout (60s default) for CSV imports of more than 10K rows — chunk those into batches of ~500 rows per RPC call using the batched import pattern from follow-up #2.
Production checklist
Steps to take before you share the URL with real users.
Security
Verify the stock table has no INSERT/UPDATE client policies
Cloud tab → Database → Table Editor → stock → Policies. Confirm there are only SELECT policies for authenticated users and NO INSERT, UPDATE, or DELETE policies. All writes must go through record_movement(). If any write policy exists, drop it.
Set staff/admin role in app_metadata for every team member
Cloud tab → Users & Auth → click each user row → Edit → set app_metadata to {"role": "staff"} or {"role": "admin"}. Admin users get access to Products edit, Locations, Import, and Settings.
Domain & SSL
Point a custom subdomain at the published app
Publish → top-right toolbar → Settings → Custom domain. Add a CNAME record at your DNS provider pointing inventory.yourcompany.com to your lovable.app URL. SSL is auto-provisioned.
Monitoring
Confirm the daily cron and Edge Function run successfully
Cloud tab → Edge Functions → check-low-stock → Logs. Verify one run per day at ~06:00 UTC with a 200 status. If it fails, check that RESEND_API_KEY is set in Cloud tab → Secrets and that the pg_cron schedule URL was updated to the actual Edge Function URL.
Backups
Confirm daily backups are running before going live with real inventory
Cloud tab → Database → Backups. Verify the most recent backup is from today. Export a manual backup before any bulk CSV import to ensure you can restore if the import goes wrong.
Frequently asked questions
Why store a qty column separately from movements instead of always computing SUM(delta)?
Computing SUM(delta) for every product+location on every page load requires scanning the full movements table — fine at 1,000 rows, very slow at 1 million. The stock table with a materialized qty is your fast read path: O(1) lookup for current stock. The movements table is your audit trail and source of truth for reconciliation. The record_movement() RPC keeps both in sync atomically — you never compute from movements in the UI, you just display stock.qty.
How do I prevent negative stock when two users sell the same SKU simultaneously?
The CHECK (qty >= 0) constraint on the stock table is your primary defense. When two concurrent sells of the last unit both reach record_movement(), the PostgreSQL row-level lock inside the UPSERT serializes them: one succeeds and the other hits the CHECK constraint with a 23514 error. Catch that error code in your UI and show 'Out of stock — this item was just sold by another team member'. Do not remove the constraint and do not rely solely on client-side checks.
Can I use a barcode scanner from my phone?
Yes — follow-up #4 adds a /movements/quick-receipt route using @zxing/library that activates your phone's camera, decodes QR or 1D barcodes, looks up the SKU, and pre-fills a receipt form. Open the published app (not the preview) from your phone browser — the camera permission dialog appears on first use. On desktop, a text input fallback lets you type or paste SKU codes. No app install required.
How do I import my existing inventory from a spreadsheet?
Follow-up #2 adds a CSV import route. Export your spreadsheet as CSV with columns: sku, name, unit, reorder_point, location_name, qty. Go to /import, drop in the file, review the per-row validation preview (green = valid, red = error with explanation), then click Confirm. The Edge Function validates all rows first, and only imports if every row passes — if row 47 fails, nothing is imported and you get a specific error for that row to fix.
What if I have stock across 5 or more warehouses?
The schema handles unlimited locations from day one — the stock table is keyed by (product_id, location_id), so you can add a third, fourth, or tenth location from /locations and immediately record movements at that location. The /stock page's location filter pill lets you scope the view to a single location. The transfer follow-up (follow-up #3) adds an atomic transfer flow between any two locations.
Should I sync this with Shopify or Square?
Follow-up #6 (in the brief) outlines a Shopify sync via the native Cloud App connector: an Edge Function polls Shopify inventory and reconciles each SKU via record_movement() with reason='adjustment'. This is appropriate when your online store sells the same physical SKUs as your Lovable system. For Square (POS), there's no native connector — you'd use the Square REST API from an Edge Function on a schedule. Both sync patterns work, but expect to spend 80–100 credits on the integration and ongoing maintenance when Shopify/Square change API formats.
When should I upgrade to a real WMS like NetSuite or Fishbowl?
Upgrade when you need WMS-grade capabilities: pick paths, wave picking, multi-warehouse replenishment, lot/serial number tracking for FDA/ISO compliance, EDI integration with suppliers, or real-time POS integration across many terminals. At that point you're past $100K/year in inventory complexity and Lovable's 60-second Edge Function timeout and 500MB DB become real constraints. If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.
Need a production-grade version?
RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.
Book a free consultation30-min call. No commitment.