Skip to content
Ali Hassan — home

Work

PeopleCore HR

A multi-tenant HR product where every company's data is isolated in the database itself, covering people, time off, attendance, onboarding, documents, projects, feedback and reports.

Role
Full-stack engineer
Status
Built
  • Next.js
  • TypeScript (strict)
  • Tailwind CSS
  • Supabase (Postgres, RLS, Auth, Storage, Realtime)
  • Zod
Settings, departments and leave-type configuration in a multi-tenant HR platform with row-level security per tenant.

The problem

Context
A multi-tenant HR product: each company is an organisation, and people join it with a role (admin, HR, manager or employee). It covers the people directory, time off, attendance, onboarding, documents, projects, announcements, feedback and reports, with a platform owner who onboards companies from above.
Constraints
Many companies share one Postgres database on Supabase, so isolation had to hold in the database itself, not only in application code. TypeScript runs in strict mode, validation is shared between browser and server, and there is no open admin sign-up: companies are onboarded through the owner console with one-time setup links.
What was at stake
HR data is the most sensitive a company holds. One missed company filter in a query would show one company's employees and private documents to another, and two overlapping leave requests approved at once would corrupt a balance.

What I built

Tenant isolation, enforced twice

  • Row-level security on every tenant table

    Every table that holds company data has row-level security, written through a small set of database functions that check membership and role for the signed-in user. On top of that, employees see their own rows, managers their reports', and admins and HR everything in their company.

  • Application guards as the second layer

    Server code also checks membership, role and permission before acting, so a bug in either layer is caught by the other.

  • A privileged path only for the platform owner

    The owner console sits above all companies through a separate, server-only provisioning path, used only after the owner check passes and never exposed to the browser.

Data integrity

  • Overlapping leave rejected by the database

    An exclusion constraint makes two pending or approved requests for the same person and overlapping dates impossible to store, whatever the application does. The app catches the rejection and shows a plain message.

  • Balances that cannot drift

    Creating, approving, rejecting or cancelling leave runs as one database function that re-checks permission, locks the balance row, recomputes what is available, moves days between pending and used, and writes an audit entry, all in one transaction.

Documents and audit

  • Private files behind short-lived links

    Employee documents and leave attachments sit in a private bucket and are handed out only as short-lived signed links, after a membership and ownership check. A failed record save removes the uploaded file, so nothing is left orphaned.

  • An audit log the database keeps append-only

    The audit table allows reads by admins and HR and inserts by members, and has no update or delete rule at all, so entries cannot be changed or removed. Before and after values are stored for sign-ins, leave decisions, settings and document changes.

Operations

  • Schema history and one-shot setup

    Nineteen numbered migrations record how the schema evolved, and a single idempotent schema file sets up a fresh database in one run. Configuration is validated on use, so a missing variable fails fast instead of at the first request.

Tell me what you’re building and where it’s stuck.

I’ll tell you the cleanest path forward, including if it’s “don’t build that.”

Or write tocontact@alihassan.dev

Ali Hassan in a dark winter jacket, looking off to one side, standing in a stone courtyard with a minaret and cloudy sky behind him.