Skip to main content
RapidDev - Software Development Agency
Lovable PromptsB2B SaaSIntermediate

Build an HR Management System in Lovable

A multi-tenant HR system with employee directory, leave-request workflow with manager approval, per-employee document storage with folder-scoped RLS, three-tier role hierarchy (hr_admin / manager / employee), audit log on document reads, and Resend invite and notification emails.

Time to MVP

1–2 days

Credits

~150–250 credits for full chain

Difficulty

Intermediate

Cloud features

4

TL;DR

The one-paragraph version before you dive in.

Paste the starter prompt into Lovable Build mode and get a multi-tenant HR system with an employee directory, leave-request workflow with approval chain, private document storage scoped per employee, three-tier role access (HR admin, manager, employee), and Resend email notifications. Build time is 1–2 days. Expected credit burn is 150–250 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 6 tables including the high-PII employees and documents tables, three SECURITY DEFINER functions for multi-tenant + role-tier RLS, and an audit_log trigger on every documents SELECT.

  1. 1Click the + button next to Preview in the top toolbar to open the Cloud tab
  2. 2Click Database — you will see an empty Table Editor
  3. 3Leave it empty — the starter prompt creates all 6 tables, 3 SECURITY DEFINER functions, all RLS policies, the Storage bucket, folder-scoped Storage policy, and the audit_log trigger in one migration

Auth

Email+password and Google OAuth for employees. HR admins invite employees via signed tokens; employees claim their seat on the /onboarding page.

  1. 1Cloud tab → Users & Auth
  2. 2Confirm Email is enabled (it is by default)
  3. 3Enable Google OAuth if desired: toggle Google ON → paste Client ID and Client Secret from your Google Cloud Console → Save
  4. 4Leave 'Allow new users to sign up' ON — employees will sign up via invite links; new company founders sign up directly

Storage

Holds employee documents (contracts, IDs, tax forms, reviews) in a private bucket with folder-scoped RLS that prevents Employee A from reading Employee B's files.

  1. 1Cloud tab → Storage
  2. 2Click Create bucket, name it 'employee-documents', leave Public OFF
  3. 3The starter prompt migration will create the folder-scoped RLS policies on storage.objects — you do not need to configure them here

Edge Functions

invite-employee sends Resend invite emails with signed JWT tokens; approve-leave handles state machine transitions and sends Resend notifications.

  1. 1Cloud tab → Edge Functions
  2. 2No setup needed before the starter — follow-ups #2 and #3 scaffold these functions
  3. 3After those follow-ups, add RESEND_API_KEY and INVITE_JWT_SECRET to Cloud tab → Secrets

Secrets (Cloud tab → Secrets)

RESEND_API_KEY

Purpose: Enables invite-employee to send invitation emails and approve-leave to send leave-status notifications to employees and managers

Where to get it: Sign up at resend.com → API Keys → Create API Key. Copy the key starting with re_.

INVITE_JWT_SECRET

Purpose: Signs and verifies invite tokens in the /onboarding?token= link. Without this, invite links can be forged or cross-claimed.

Where to get it: Generate a random 32-character string using a password manager generator. Do not use the same secret as any other app.

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 ~70–100 credits given the PII-grade RLS complexity; the full chain reaches 150–250 credits
  • You are fully committed to PII-grade RLS from day one: CVE-2025-48757 found 10.3% of audited Lovable apps (170 of 1,645) leaking employee PII because of missing or incorrect RLS. This kit fixes that — do not skip the RLS audit follow-up.

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.

lovable-agent-mode.txt
~70–100 credits
Build a multi-tenant HR management system. Stack: React + Vite + TypeScript + Tailwind + shadcn/ui (already scaffolded), React Router, Supabase JS client against Lovable Cloud.

IMPORTANT: This app handles employee PII (names, contact info, salary bands, medical leave categories, document scans). Multi-tenant RLS AND per-employee Storage RLS must both be correct. CVE-2025-48757 audited 1,645 Lovable apps and found 170 (10.3%) leaking exactly this data class.

## Database schema (create one migration)

Create 6 tables with RLS enabled on ALL of them:

1. `companies` — id (uuid pk default gen_random_uuid()), name (text not null), country_code (text), created_by (uuid references auth.users(id)), created_at (timestamptz default now()).

2. `members` — id (uuid pk default gen_random_uuid()), company_id (uuid not null references companies(id) on delete cascade), user_id (uuid references auth.users(id) on delete cascade), role (text not null default 'employee', check role in ('hr_admin','manager','employee')), employee_id (uuid — FK to employees, set after profile creation), created_at (timestamptz default now()). UNIQUE(company_id, user_id).

3. `employees` — id (uuid pk default gen_random_uuid()), company_id (uuid not null references companies(id)), full_name (text not null), work_email (text not null), personal_email (text), phone (text), role_title (text), manager_id (uuid references employees(id)), start_date (date), salary_band (text — e.g. 'L4','L5' — NEVER store raw salary amounts), status (text default 'active', check in ('active','on_leave','terminated')), created_at (timestamptz default now()).

4. `leave_requests` — id (uuid pk default gen_random_uuid()), employee_id (uuid references employees(id)), type (text check in ('vacation','sick','parental','bereavement','other')), starts_on (date not null), ends_on (date not null, check ends_on >= starts_on), reason (text), status (text default 'pending', check in ('pending','approved','rejected','cancelled')), reviewer_id (uuid references auth.users(id)), reviewed_at (timestamptz), created_at (timestamptz default now()).

5. `documents` — id (uuid pk default gen_random_uuid()), employee_id (uuid references employees(id)), category (text check in ('contract','id','tax_form','review','other')), filename (text not null), storage_path (text not null — format: {employee_id}/{uuid}-{filename}), uploaded_by (uuid references auth.users(id)), expires_on (date), created_at (timestamptz default now()).

6. `audit_log` — id (uuid pk default gen_random_uuid()), actor_id (uuid references auth.users(id)), action (text not null), entity_type (text not null), entity_id (uuid), payload (jsonb), created_at (timestamptz default now()).

Create these three SECURITY DEFINER plpgsql functions (NEVER SQL-language — they get inlined):

```sql
CREATE OR REPLACE FUNCTION is_company_member(p_company_id uuid)
RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER STABLE AS $$
BEGIN
  RETURN EXISTS (
    SELECT 1 FROM members
    WHERE company_id = p_company_id AND user_id = auth.uid()
  );
END;
$$;

CREATE OR REPLACE FUNCTION has_company_role(p_role text)
RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER STABLE AS $$
BEGIN
  RETURN EXISTS (
    SELECT 1 FROM members m
    JOIN employees e ON e.id = m.employee_id
    WHERE m.user_id = auth.uid() AND m.role = p_role
  );
END;
$$;

CREATE OR REPLACE FUNCTION is_manager_of(p_employee_id uuid)
RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER STABLE AS $$
BEGIN
  RETURN EXISTS (
    SELECT 1 FROM employees e
    JOIN members m ON m.employee_id = e.id
    WHERE e.id = p_employee_id
      AND e.manager_id IN (
        SELECT employee_id FROM members WHERE user_id = auth.uid()
      )
  );
END;
$$;
```

RLS policies (NEVER subquery members from a members policy):
- companies: SELECT via is_company_member(id); UPDATE via has_company_role('hr_admin')
- members: SELECT via is_company_member(company_id); INSERT WITH CHECK (is_company_member(company_id)) for invite acceptance
- employees: SELECT via (own row: employee_id IN (SELECT employee_id FROM members WHERE user_id=auth.uid())) OR is_manager_of(id) OR has_company_role('hr_admin'); INSERT/UPDATE via has_company_role('hr_admin')
- leave_requests: SELECT via (employee is own: employee_id IN (SELECT employee_id FROM members WHERE user_id=auth.uid())) OR is_manager_of(employee_id) OR has_company_role('hr_admin'); INSERT via (own employee_id); UPDATE via is_manager_of(employee_id) OR has_company_role('hr_admin')
- documents: SELECT via (employee is own: employee_id IN (SELECT employee_id FROM members WHERE user_id=auth.uid())) OR has_company_role('hr_admin'); INSERT via is_company_member((SELECT company_id FROM employees WHERE id = employee_id))
- audit_log: SELECT via has_company_role('hr_admin'); INSERT via service role only

Also create a SECURITY DEFINER function `create_company(p_name text, p_country_code text) RETURNS uuid` that atomically inserts a companies row AND a members row with role='hr_admin'.

Storage bucket and policies:
Create bucket `employee-documents` (private). Add these storage.objects RLS policies:

```sql
-- Employee reads own documents
CREATE POLICY employee_own_docs ON storage.objects FOR SELECT TO authenticated
USING (
  bucket_id = 'employee-documents'
  AND (storage.foldername(name))[1] IN (
    SELECT employee_id::text FROM members WHERE user_id = auth.uid()
  )
);

-- HR admin reads all documents in their company
CREATE POLICY hr_admin_all_docs ON storage.objects FOR SELECT TO authenticated
USING (
  bucket_id = 'employee-documents'
  AND (storage.foldername(name))[1] IN (
    SELECT e.id::text FROM employees e
    JOIN members m ON m.company_id = e.company_id
    WHERE m.user_id = auth.uid() AND m.role = 'hr_admin'
  )
);

-- Employee uploads to own folder only
CREATE POLICY employee_upload_own ON storage.objects FOR INSERT TO authenticated
WITH CHECK (
  bucket_id = 'employee-documents'
  AND (storage.foldername(name))[1] IN (
    SELECT employee_id::text FROM members WHERE user_id = auth.uid()
  )
);
```

Audit trigger: Create a trigger `audit_documents_select` AFTER SELECT ON documents FOR EACH ROW that calls a function inserting a row into audit_log with action='document_read', entity_type='documents', entity_id=OLD.id.

## Layout

Create `src/layouts/AppLayout.tsx` — top bar with CompanySwitcher dropdown (reads members rows for current user, lists company names) + user menu; left sidebar with nav (Dashboard, Employees, Leave, Documents, Settings). Admin-only: Settings.

Create `src/contexts/CompanyContext.tsx` — stores current company_id from URL `/c/:companyId`, exposes useCompany() hook used by all queries.

Create `src/components/AuthGuard.tsx` — redirects to /login if no session.

Create `src/components/CompanyGuard.tsx` — verifies is_company_member(company_id), redirects to /onboarding or first available company.

Create `src/components/RoleGate.tsx` — accepts allowedRoles: string[] prop, reads current member role from CompanyContext, renders children only if user's role is in allowedRoles, otherwise renders null or a 'Contact HR admin' message.

## Pages and routes

Create these routes:
- `/login` — email+password + Google OAuth
- `/onboarding` — two cards: 'Create company' (calls create_company RPC) and 'Accept invite via token' (reads URL token, verifies, claims pending members row)
- `/c/:companyId` — dashboard: headcount card + pending leave count + expiring documents count; role-aware widgets (employee sees own leave balance; manager sees direct reports' requests; hr_admin sees company-wide)
- `/c/:companyId/employees` — directory list with search by name/email; card view with EmployeeCard per employee; RoleGate: all roles see it but employees see only their own card, managers see direct reports, hr_admin sees all
- `/c/:companyId/employees/:employeeId` — employee profile: basic info + leave history + documents list; RoleGate on salary_band field (hr_admin only)
- `/c/:companyId/leave` — leave list: 'My requests' tab always visible; 'Direct reports' tab for managers; 'All requests' tab for hr_admin; filter by status/type/date
- `/c/:companyId/leave/new` — leave request form: type, starts_on, ends_on (shows working-days count), reason (optional); submits to leave_requests INSERT
- `/c/:companyId/documents` — per-employee document browser (scoped by role); DocumentUploader for the employee's own folder; DocumentList with download (signed URL, 1-hour expiry) and delete
- `/c/:companyId/settings` — company name, leave-type config, HR admins list; hr_admin only via RoleGate

## Components

Create:
- `src/components/EmployeeCard.tsx` — avatar + full_name + role_title + manager name + status badge (active/on_leave/terminated)
- `src/components/LeaveRequestForm.tsx` — shadcn Dialog with type Select, date pickers for starts_on/ends_on, auto-calculated working days, reason textarea
- `src/components/LeaveApprovalCard.tsx` — RoleGate(hr_admin, manager) — shows employee name, leave type, dates, reason, Approve and Reject buttons calling leave_requests UPDATE
- `src/components/DocumentUploader.tsx` — file picker (PDF and image, max 10MB). On select, fetches caller's employee_id from members table (NOT auth.uid() — they are different IDs). Uploads to `employee-documents/{employee_id}/{crypto.randomUUID()}-{filename}`. Inserts a documents row after successful upload.
- `src/components/DocumentList.tsx` — list of documents with filename, category badge, expiry date chip. Download button generates a signed URL (3600s expiry) and opens in new tab. Delete button removes the Storage object and the documents row.

## Styling

Light + dark mode via shadcn ThemeProvider. Primary color: indigo-600 (calm HR feel). Leave status badges: gray (pending) / emerald (approved) / rose (rejected). Employee status: emerald (active) / amber (on_leave) / gray (terminated). Document category color-coded badges. Sticky filter bar on leave page.

Generate the migration first — confirm it includes all 3 SECURITY DEFINER functions, all 6 table RLS policies, the 3 Storage policies, the create_company() RPC, and the audit trigger. Then generate the contexts + guards + layout. Then pages in the order listed.

What this prompt generates

  • Creates a SQL migration with 6 tables, 3 SECURITY DEFINER functions (is_company_member, has_company_role, is_manager_of), all RLS policies, 3 folder-scoped Storage policies on employee-documents, create_company() RPC, and audit_log trigger
  • Scaffolds AppLayout with CompanySwitcher and sidebar, CompanyContext, AuthGuard, CompanyGuard, and RoleGate
  • Generates 9 page components (Login, Onboarding, Dashboard, Employees, EmployeeDetail, Leave, LeaveNew, Documents, Settings) wired to React Router
  • Builds EmployeeCard, LeaveRequestForm, LeaveApprovalCard, DocumentUploader (with correct employee_id path), and DocumentList with signed URL downloads
  • Wires audit_log trigger so every documents SELECT creates a row in audit_log — required for GDPR DSAR compliance

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_hr_schema.sql

6 tables + 3 SECURITY DEFINER functions + all RLS policies + Storage bucket + 3 folder-scoped Storage policies + create_company RPC + audit trigger

src/contexts/CompanyContext.tsx

Current company_id/name from URL, exposes useCompany() hook

src/layouts/AppLayout.tsx

Top bar with CompanySwitcher + sidebar nav

src/components/AuthGuard.tsx

Redirects to /login if no session

src/components/CompanyGuard.tsx

Verifies company membership, redirects to /onboarding or first company

src/components/CompanySwitcher.tsx

Dropdown listing user's companies from members rows

src/components/RoleGate.tsx

Conditionally renders children based on current member role

src/components/EmployeeCard.tsx

Avatar + name + title + manager + status badge

src/components/LeaveRequestForm.tsx

Leave request Dialog with type, dates, working-day count, and reason

src/components/LeaveApprovalCard.tsx

Approve/Reject card visible only to managers and hr_admins

src/components/DocumentUploader.tsx

File picker that uploads to {employee_id}/{uuid}-{filename} path with correct RLS path

src/components/DocumentList.tsx

Document list with signed URL download and delete

src/pages/Login.tsx

Email+password + Google OAuth sign-in

src/pages/Onboarding.tsx

Create company (via create_company RPC) or accept invite token

src/pages/CompanyDashboard.tsx

Role-aware KPI widgets

src/pages/Employees.tsx

Employee directory with role-scoped visibility

src/pages/EmployeeDetail.tsx

Full employee profile with leave history and documents

src/pages/Leave.tsx

Leave list with My / Direct Reports / All tabs by role

src/pages/LeaveNew.tsx

Leave request submission form

src/pages/Documents.tsx

Per-employee document browser with upload and signed download

src/pages/CompanySettings.tsx

Company config + HR admins + leave types (hr_admin only)

Routes
/login

Email+password and Google OAuth sign-in

/onboarding

Create company or accept invite token

/c/:companyId

Role-aware dashboard with headcount, leave, and document KPIs

/c/:companyId/employees

Employee directory with role-scoped visibility

/c/:companyId/employees/:employeeId

Employee profile with documents and leave history

/c/:companyId/leave

Leave list with My / Direct Reports / All tabs

/c/:companyId/leave/new

Leave request form

/c/:companyId/documents

Per-employee document browser with upload and download

/c/:companyId/settings

Company config (hr_admin only)

DB Tables
companies
ColumnType
iduuid primary key default gen_random_uuid()
nametext not null
country_codetext
created_byuuid references auth.users(id)

RLS: SELECT via is_company_member(id); UPDATE via has_company_role('hr_admin')

members
ColumnType
iduuid primary key default gen_random_uuid()
company_iduuid not null references companies(id) on delete cascade
user_iduuid references auth.users(id) on delete cascade
roletext not null default 'employee'
employee_iduuid

RLS: SELECT via is_company_member(company_id) — NEVER subquery members from this policy. INSERT WITH CHECK (is_company_member(company_id)) for invite acceptance.

employees
ColumnType
iduuid primary key default gen_random_uuid()
company_iduuid not null references companies(id)
full_nametext not null
work_emailtext not null
manager_iduuid references employees(id)
salary_bandtext
statustext default 'active'

RLS: Three-tier SELECT: own row for employee; direct reports via is_manager_of(); all in company via has_company_role('hr_admin')

leave_requests
ColumnType
iduuid primary key default gen_random_uuid()
employee_iduuid references employees(id)
typetext
starts_ondate not null
ends_ondate not null
statustext default 'pending'
reviewer_iduuid references auth.users(id)

RLS: SELECT: own / direct reports (is_manager_of) / all (hr_admin). INSERT by employee for own requests. UPDATE status by manager or hr_admin.

documents
ColumnType
iduuid primary key default gen_random_uuid()
employee_iduuid references employees(id)
categorytext
filenametext not null
storage_pathtext not null
expires_ondate

RLS: SELECT: own employee / hr_admin for all in company. Every SELECT writes to audit_log via trigger. Storage policy enforces folder prefix matches employee_id.

audit_log
ColumnType
iduuid primary key default gen_random_uuid()
actor_iduuid references auth.users(id)
actiontext not null
entity_typetext not null
entity_iduuid
payloadjsonb
created_attimestamptz default now()

RLS: SELECT by has_company_role('hr_admin') only; INSERT via trigger or record_audit() SECURITY DEFINER function

Components
CompanyGuard

Verifies is_company_member(), redirects to /onboarding or first company

RoleGate

Conditionally renders content based on current member role

EmployeeCard

Avatar + name + title + manager + status badge

LeaveRequestForm

Leave request Dialog with type, dates, and working-day count

LeaveApprovalCard

Approve/Reject UI visible only to managers and hr_admins

DocumentUploader

File picker that uploads to correct {employee_id}/{uuid}-{filename} path

Follow-up prompts

Paste these into Agent Mode one by one, in order, after the starter prompt finishes.

1

Audit PII-grade RLS (non-negotiable — do this first)

Verified PII-grade data isolation — no cross-employee or cross-company document access

~50 credits
prompt
Audit every RLS policy and every Storage policy:
1. members SELECT: must use is_company_member(company_id), NOT a raw subquery.
2. documents SELECT: must include the folder-prefix check (storage.foldername(name))[1] IN (employee_id::text values the caller is authorized to see).
3. employees SELECT: confirm three-tier visibility — employee sees own, manager sees direct reports, hr_admin sees all.

Then run a test with 3 accounts in 2 companies:
- Company A: hr_admin account + employee account.
- Company B: employee account.
- Sign in as Employee A1 in Company A. Confirm they see ONLY their own document list, ZERO from Employee A2 and ZERO from Company B.
- Sign in as HR Admin in Company A. Confirm they see ALL Company A documents but ZERO from Company B.
- Attempt to construct the Storage path of Company B's employee document and fetch it as Company A's employee — must return 403.

Output any failing policies with corrected SQL.

When to use: Immediately after the starter prompt finishes, before any real employee data goes in

2

Add invite-employee flow with signed token

Secure invite-by-email flow with signed JWT tokens that expire in 72 hours and validate email identity before claiming

~80 credits
prompt
Create a Supabase Edge Function at `supabase/functions/invite-employee/index.ts` (Deno). It should:
1. Verify the caller's Authorization header and confirm they have role='hr_admin' in the target company.
2. Accept POST body: `{company_id, email, role, intended_employee_id (optional)}`.
3. Create a members row with company_id, invited_email=email, user_id=null, role.
4. Sign a JWT (INVITE_JWT_SECRET from Deno.env.get) with payload `{company_id, invited_email: email, member_id: row.id, exp: now+72h}`.
5. Send a Resend email (RESEND_API_KEY from Deno.env.get) to the invited email with subject 'You have been invited to [Company Name] HR' and a link to `/onboarding?token=<signed_jwt>`.

Update /onboarding 'Accept invite' flow:
1. Read token from URL. If no auth session, store in localStorage and redirect to /login?next=/onboarding.
2. After login, read from localStorage. Decode and verify JWT signature against INVITE_JWT_SECRET.
3. Verify `claims.invited_email === user.email` (case-insensitive). Reject if mismatch.
4. UPDATE members SET user_id = auth.uid(), invited_email = null, accepted_at = now() WHERE id = claims.member_id AND invited_email = claims.invited_email AND user_id IS NULL.

When to use: When adding employees beyond your own account

3

Wire leave-request approval workflow with notifications

Resend emails to employees on approval/rejection and to managers on new leave submissions, with opt-out toggle

~60 credits
prompt
Create a Supabase Edge Function at `supabase/functions/notify-leave-update/index.ts` (Deno). It should:
1. Be invoked by a Postgres trigger on leave_requests AFTER UPDATE OF status.
2. On status change to 'approved' or 'rejected': send a Resend email to the employee's work_email with the new status, reviewer name, and leave dates.
3. On a new leave_request INSERT with status='pending': send a Resend email to the employee's manager (if set) with an approve/reject action link to `/c/:companyId/leave` filtered to that request.
4. Read a notify_email boolean column on members — add it with ALTER TABLE if not present. Only send if notify_email = true.

In the LeaveApprovalCard component, add a loading state while the UPDATE is pending. After a successful update, show a shadcn Toast: 'Leave request approved — [Employee] has been notified'.

When to use: Once the leave form works in testing and you have 2 or more employees

4

Add document expiry reminders

Daily 08:00 UTC expiry-reminder email to hr_admin listing documents expiring in the next 30 days

~40 credits
prompt
Create a Supabase Edge Function at `supabase/functions/check-document-expiry/index.ts` (Deno) that:
1. Queries documents WHERE expires_on BETWEEN now() AND now() + interval '30 days' AND resolved_at IS NULL, joined to employees (full_name, company_id) and companies (name).
2. Groups by company and sends one Resend digest email per hr_admin in each company. Subject: 'Documents expiring in the next 30 days — [Company Name]'. Body: table of employee name, document category, filename, expiry date.

Schedule it via pg_cron: in a new SQL migration, add `SELECT cron.schedule('check-doc-expiry-daily', '0 8 * * *', $$SELECT net.http_post(...)$$)`. Document that the Edge Function URL must be set after deploy.

In the /c/:companyId dashboard, add a 'Documents expiring soon' card (hr_admin only via RoleGate) showing count + list of expiring items linking to the employee's document page.

When to use: Once you start storing contracts or ID documents with expiry dates

5

Add audit log viewer for HR admin

Paginated audit log viewer with filters and CSV export for GDPR DSAR responses and SOC2 audits

~50 credits
prompt
Add a new route `/c/:companyId/settings/audit` (hr_admin only via RoleGate). Build an AuditLogPage component that:
1. Queries audit_log rows filtered by company_id (join through entity_id to employees.company_id, or add company_id column to audit_log in a new migration).
2. Renders a paginated table (25 rows/page) with columns: actor name (join to profiles), action, entity_type, entity_id (linked to the employee's profile page), relative timestamp ('2 hours ago'), payload diff (collapsible Code block).
3. Filter controls: actor selector, action dropdown, date range picker.
4. 'Export to CSV' button that calls an audit-export Edge Function returning the filtered rows as CSV.

Add a link to the audit log in /c/:companyId/settings sidebar for hr_admin role only.

When to use: For GDPR compliance or when you need to answer 'who accessed this employee's records and when'

6

Add role-based dashboard widgets

Role-specific dashboard widgets showing each user type only the information relevant to their responsibilities

~50 credits
prompt
Update the /c/:companyId dashboard to render different widgets per role:

For 'employee' role:
- 'My leave balance' card: remaining vacation days (calculate from approved vacation leave_requests this year vs company leave-year allocation from settings).
- 'My pending requests' card: count of own leave_requests with status='pending'.
- 'My expiring documents' card: documents WHERE employee_id = own AND expires_on < now() + 60 days.

For 'manager' role (in addition to own):
- 'Direct reports pending leave' card: count of leave_requests WHERE employee_id IN (direct reports) AND status='pending'. Click through to filtered leave page.

For 'hr_admin' role (in addition to above):
- 'Company-wide pending leave' card.
- 'Documents expiring soon' card (30-day window).
- 'Headcount by status' card: count of employees grouped by status.

Use RoleGate to show/hide each widget section. Layout: 2-up grid on desktop, 1-up on mobile.

When to use: When a one-size-fits-all dashboard feels noisy to employees vs the HR admin view

Common errors

Real error strings you'll see. Find yours, paste the fix prompt.

infinite recursion detected in policy for relation "members"

Same multi-tenant RLS recursion as the project-management kit — your members SELECT policy subqueries the members table itself instead of going through the is_company_member() SECURITY DEFINER function. This is the most common Lovable multi-tenant build failure.

Fix — paste into Lovable Agent Mode
Create `is_company_member(p_company_id uuid) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER STABLE AS $$ BEGIN RETURN EXISTS (SELECT 1 FROM members WHERE company_id = p_company_id AND user_id = auth.uid()); END; $$;`. Drop the current members SELECT policy. Recreate it: `CREATE POLICY members_company_visible ON members FOR SELECT TO authenticated USING (is_company_member(company_id));`. Do NOT use a SQL-language function — they get inlined by the optimizer and still recurse.
Employee A can download Employee B's passport scan (cross-employee document leak)

Your storage.objects policy on employee-documents checks bucket_id but NOT the folder prefix (storage.foldername(name))[1] — anyone with bucket access can read any object once they know or guess the path. This is the CVE-2025-48757 leak pattern: 170 of 1,645 audited Lovable apps had exactly this failure on PII data.

Fix — paste into Lovable Agent Mode
Drop the existing storage.objects SELECT policies on employee-documents. Recreate them as shown in the starter prompt: one policy for employees reading their own folder (`(storage.foldername(name))[1] IN (SELECT employee_id::text FROM members WHERE user_id = auth.uid())`), and one for hr_admin reading all company employee folders. Test by signing in as Employee A and attempting to construct and fetch the Storage path of Employee B's file — it must return 403.
Manager cannot see direct reports' leave requests

Your is_manager_of() function was generated as a SQL-language function, which the PostgreSQL optimizer inlines into the query plan and sometimes evaluates incorrectly, returning no rows even when the manager relationship exists.

Fix — paste into Lovable Agent Mode
Convert is_manager_of to LANGUAGE plpgsql SECURITY DEFINER STABLE (never LANGUAGE sql). Drop and recreate it with the plpgsql body from the starter prompt. Verify the leave_requests SELECT policy calls is_manager_of(employee_id) — not a raw join — and that the employees table's manager_id column is correctly set for your test employee rows.
row-level security policy error when employee tries to upload their own document

Your DocumentUploader is uploading to a path that uses auth.uid() as the folder prefix, but your Storage policy expects the employee_id from the employees table (a different UUID — the auth user ID and the employee row ID are not the same).

Fix — paste into Lovable Agent Mode
In DocumentUploader, before starting the upload, fetch the caller's employee_id: `const { data: member } = await supabase.from('members').select('employee_id').eq('user_id', (await supabase.auth.getUser()).data.user.id).eq('company_id', currentCompanyId).single();`. Use `member.employee_id` (not `user.id`) as the folder prefix: `${member.employee_id}/${crypto.randomUUID()}-${file.name}`. This matches the Storage policy's (storage.foldername(name))[1] check.
Documents appear in the list but download URL returns 403

You created a public URL but the employee-documents bucket is private. Public URLs don't work for private buckets. Or you created a signed URL with too-short an expiry and the user opened the link after it expired.

Fix — paste into Lovable Agent Mode
In DocumentList's download button handler, use: `const { data } = await supabase.storage.from('employee-documents').createSignedUrl(document.storage_path, 60 * 60); if (data?.signedUrl) window.open(data.signedUrl, '_blank');`. Use 3600 seconds (1 hour) expiry. The signed URL respects the storage.objects RLS policy at sign time, so only callers authorized to SELECT the document can generate a working link.
Second invite link claimant logs in as the first invitee (identity collision)

The /onboarding token handler claims the first pending members row matching the company_id instead of validating the token's claimed email matches the signed-in user's email.

Fix — paste into Lovable Agent Mode
In the /onboarding token handler, decode the JWT, check `claims.invited_email.toLowerCase() === user.email.toLowerCase()`. If they don't match, show error 'This invite was not sent to your email address.' and do not update any row. Update ONLY the row matching both `id = claims.member_id` AND `invited_email = claims.invited_email` AND `user_id IS NULL` — all three conditions together prevent collision.

Cost reality

What this build actually costs — no surprises on your card.

Recommended Lovable plan

Pro $25/mo recommended — PII-grade RLS auditing, multi-tenant policies, document Storage policies, and the audit trigger are where credits go. Budget one $15 top-up for the invite flow iteration.

Monthly run cost breakdown

~150–250 credits estimated (starter ~70–100, follow-ups 1–3 ~190, follow-ups 4–6 add ~140 more if all done). Note: the closest documented Lovable build is a gig work management platform, so credit ranges are extrapolated from multi-tenant + storage + role-hierarchy patterns. total
ItemCost
Lovable Pro

Can drop to Free after launch — the app continues to run on Cloud

$25/mo while iterating
Supabase / Lovable Cloud

Free tier: 500MB DB holds hundreds of employees and thousands of leave requests; 1GB Storage holds ~250 employee documents at 4MB average

$0/mo at MVP scale
Resend

Invite + leave approval + expiry reminders — 3K/mo covers a 100-employee company

$0/mo up to 3K emails
Custom domain

Point hr.yourcompany.com at the published app

~$10–15/yr

Scaling notes: The 1GB Storage Free tier fills quickly if you store high-res document scans. Roughly 250 employees × 4MB average = 1GB, which hits the ceiling. At that point, either move documents to AWS S3 (via App connector) to keep Supabase costs flat, or upgrade to Supabase Pro $25/mo for 100GB Storage. The DB itself is rarely the bottleneck for a sub-500 employee company.

Production checklist

Steps to take before you share the URL with real users.

Security

  • Run cross-company and cross-employee isolation test before any real data

    Create 3 test accounts: hr_admin in Company A, employee in Company A, employee in Company B. In separate incognito windows, verify: Company A employee sees only their own documents; Company B employee sees zero from Company A; hr_admin in Company A sees all Company A documents but zero from Company B. Also try constructing Company B employee's file URL while signed in as Company A — must return 403.

  • Verify invite token validates email before claiming

    Create an invite for test@example.com. Sign in with a different email. Try to use the invite URL — the onboarding handler must reject with 'This invite was not sent to your email address.' Only the exact invited email can claim the seat.

Domain & SSL

  • Set a custom domain and update Google OAuth redirect URIs

    Publish → Settings → Custom domain. Add CNAME at your DNS provider. After the domain propagates, update your Google Cloud Console OAuth app's authorized redirect URIs to include the new domain, otherwise Google sign-in breaks on the custom domain.

Backups

  • Confirm daily backups before any real employee data is entered

    Cloud tab → Database → Backups. Verify the most recent backup is from today. Download a manual backup now as a pre-launch checkpoint. For GDPR compliance, document your backup retention policy (Lovable Cloud retains ~14 days).

Compliance

  • Verify audit_log trigger fires on document reads

    Sign in as hr_admin, open an employee's document list, click download. Then go to /c/:companyId/settings/audit (after building follow-up #5). Confirm a 'document_read' row appears in the log with the correct actor_id and entity_id. If no row appears, the trigger was not created — add it in a new migration using the trigger definition from the starter prompt.

Frequently asked questions

How do I prevent employees from seeing each other's documents?

The Storage policy on employee-documents uses a folder-prefix check: (storage.foldername(name))[1] must match the employee_id of a row the caller is authorized to see. This means Employee A's file lives at /A-employee-uuid/filename and Employee B cannot read it even if they guess the path — the signed URL generation call itself fails with 403. The starter prompt includes the exact SQL for both the employee-reads-own and hr_admin-reads-all policies. Run the cross-employee test from the production checklist before going live.

Can I add region-specific leave types (for example, Canadian provincial leave or EU parental leave)?

Yes — the leave_requests.type column uses a check constraint, and the company table has a country_code column for this reason. After building the starter, add a leave_types table (id, company_id, name, days_allowed, country_code) and replace the hardcoded check constraint on leave_requests with a foreign key to leave_types. Use /c/:companyId/settings to configure custom leave types per company. The starter prompt's check constraint is intentionally simple for MVP — the follow-up settings route is where you extend it.

What is the realistic risk if I get RLS wrong on this app?

CVE-2025-48757 (published 2025) audited 1,645 Lovable showcase apps and found 170 (10.3%) with critical RLS failures. The report specifically called out leaks of 'names, email addresses, phone numbers, home addresses, and financial records including personal debt amounts.' An HR system is in the highest-risk category for this failure mode because the data is exactly what that audit found exposed. The risk is not hypothetical — a misconfigured Storage bucket policy means any signed-in user can download any employee's passport scan or contract by constructing the right path.

Should I store salary as a raw number or as a band?

Store as a band (e.g., 'L4', 'L5', 'Senior IC', 'Band 3'). Raw salary numbers in a database create a data sensitivity floor that requires stricter access controls, more rigorous GDPR handling, and typically legal review of who can query them. Salary bands are easier to share with managers without exposing exact compensation. If you need exact figures for payroll calculations, use a dedicated payroll system (Gusto, ADP, Rippling) and keep only the band in Lovable.

How do I run a GDPR data subject access request (DSAR) export?

After building follow-up #5 (audit log viewer), you have a foundation for DSAR responses. For a complete export, build a DSAR Edge Function that: queries all employees rows for the subject, all leave_requests, all documents (metadata only — not the files), and all audit_log entries where entity_id matches the employee. Return as a ZIP file with a JSON data export and a list of Storage paths. The audit_log is your record of who accessed the subject's data and when, which is the key GDPR requirement. Without the audit trigger, you cannot answer that question.

When should I integrate payroll vs keep it separate?

Keep payroll in a dedicated system (Gusto for US, Deel for global, Rippling for combined HR+IT+payroll). Lovable handles the people-management and workflow side well — employee directory, leave tracking, document storage, role management — but payroll requires tax calculations, compliance filings, direct deposit integrations, and audit trails that are genuinely hard to build correctly and have serious legal consequences if wrong. Integrate by storing only the employee_id and a payroll_system_id in Lovable, then use webhooks or CSV exports to sync leave-approved dates to your payroll system.

Can RapidDev build this with full audit-log compliance?

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 consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.