← All work

Dress rental — service retail · Deployed and running

Mayar Alaa Atelier

An Arabic-only internal operations system for a dress-rental atelier, where the booking lifecycle is enforced in the database itself — not in the interface and not in the form.

Client
Mayar Alaa Atelier — A real atelier. The name and visual identity are shown with the owner's permission.
System type
Internal web operations system with two roles: admin and staff
Mayar Alaa Atelier
1 / 6

An internal, Arabic-only operations system for a dress-rental atelier. The booking lifecycle is enforced in the database itself — not in the interface and not in the form. Ten screens across 13 pages and 8 API routes, with two roles, admin and staff; the difference between them is enforced in three layers, the last of which is row-level security in the database. The interface is Arabic, right-to-left, with no language switch, and it runs from a 320px phone up to desktop as the same system rather than a reduced copy.

83,064
total lines under src/
70
SQL migration files
21/21
tables with row-level security
53
row-level security policies
5,711
Arabic strings in 41 string files
21
verification scripts, 16 of them behavioural
226
commits across 9 calendar days
Sector
Dress rental — service retail
System type
Internal web operations system, two roles: admin and staff
Surface
10 screens · 13 pages · 8 API routes
Language
Arabic only — fully right-to-left, no language switch
Devices
Mobile from 320px and desktop — the same system, not a reduced copy
Build time
9 calendar days (22–30 July 2026) · 226 commits
Users
4 — reported by the project owner on 30 July 2026, not a measured figure
Deployed at
mayar-atelier.vercel.app

The problem the system was built to prevent

This section describes the operational problem the system was built to prevent. Its source is the rules enforced in the code and in the database — it is not a description of how the atelier used to work, which is knowledge the owner has and the code does not.

Read the detailHide the detail10 points
Booking
One dress and two overlapping periods means two clients waiting for the same piece on the same day. The problem does not show up at the moment of booking; it shows up on the delivery day.
Money
Paid and remaining are two numbers said to a client at the door. If that number is typed by hand into a field, it is an opinion rather than a fact, and it can drift away from the payment record with nobody noticing.
Security deposit
The deposit is taken at booking and returned after the dress comes back. Any moment where it is unclear whether it was returned or not is money in the drawer with no known owner.
Inventory
A dress that comes back from a rental needs preparation, and preparation takes time. A dress booked for a date it is not ready for is a promise that will not be kept.
Permissions
Dress purchase prices and team salaries are not the staff member's business. Hiding the button from the screen is not protection: the device itself is shared, and developer tools are open to anyone.
Traceability
When a number changes and nobody knows who changed it, the question does not get answered — it turns into suspicion between people who work together.
Search
An Arabic name is written in more than one form. A search that returns zero results makes the staff member open a new file for a client who already exists — and a week later there are two files for one person, with her history and deposits split across them.
Losing connectivity
Handing a dress over and taking it back both happen at the door. If the network drops at that moment, the operation is either lost or recorded twice.
Knowledge
A new staff member needs to know what every status and every field means. A guide that drifts behind the screens is worse than no guide: it points her at something that does not exist while she is confident she is reading the official source.
Backups
The database's current hosting plan reports pitr_enabled: false and backups: [] — meaning there is no automatic backup of the database at all.

Where the build started, and the flow it is built around

Read the detailHide the detail

Before the first line of code, we decided what would not be built. Those decisions were taken ahead of implementation and written down in README.md section 7. Each of them removes a feature from the system — the gain is that what is left actually works.

No dress photographs: a dress is identified by its code (DR-101) and nothing else. No intermediate stage after a return either — the only question at return time is when the dress will be available to rent again, and the answer is stored in available_from and feeds straight into the booking check.

The security deposit has one fixed shape: an ID card plus cash, taken at booking, with a mandatory amount — create_booking rejects a booking without it. No printing, no invoices, no receipts. Notifications stay inside the system; not a single message goes out to a client.

Attendance is approved by the admin, with no location tracking. Sign-in is by mobile number: the word email appears on no screen, and the number is converted into an internal identifier on the .invalid domain reserved by RFC 2606. Measurements are detailed only — the text size (40) and the centimetre measurements were two descriptions of the same thing and contradicted each other, so the text size was removed in migration 0057. The backup is manual: an Excel file the admin downloads whenever she wants.

What makes this a system rather than a set of forms is one enforced flow that is not left to the interface. The booking lifecycle runs booked to delivered to returned, with cancellation available only from booked. Three legal transitions and no others; any move backwards or any skipped stage is rejected.

The guard sits on the table itself. guard_booking_status_transition (migration 0033, extended in 0039 to cover inserts) runs before insert or update on bookings, and it carries no exemption for the service_role. An illegal transition is rejected even from a script running with the server key, and it comes back as an explicit 42501 error. A new row must start at booked; a booking created in any other status is rejected. The only opening is a deliberate administrative correction (migration 0063): one step backwards, on the row named by its exact id, and locked for every other row in the same transaction.

Other rules sit around that cycle, all of them in the database: dress readiness at booking time through available_from, a GiST exclusion constraint that prevents two bookings overlapping on the same dress, a ceiling on payments, paid_amount recomputed from the payments record after every insert, update or delete, a guard that reverts any direct write to paid_amount, and a delivery that resets the deposit row to held. The practical result is that a booking and its payment record cannot disagree — and there is no path, not from the interface, not from a manual query, not from new code, that can make them disagree.

Engineering decisions that make a difference

Every one of these is verified either in the code or against the production database.

Read the detailHide the detail14 points
  1. 01

    Row-level security on every table

    Row-level security is on for 21 of 21 tables with 53 policies, with a server-side check above it. Hiding an element in the interface is a display convenience, not protection.

  2. 02

    Salaries and purchase prices live in separate tables

    staff_finance and dress_costs, each with an admin-only policy, because row-level security in the database works at the row level and not the column level.

  3. 03

    Booking status is enforced by a trigger with no service-key exemption

    An illegal transition is rejected even from a script running with the server key. This is specific to the booking status guard — other guards in the schema do start with a service_role exemption.

  4. 04

    paid_amount is computed from the payment record

    Any direct write to it is reverted to its previous value, so the booking and the record cannot diverge.

  5. 05

    Overlapping bookings are blocked by a database constraint

    bookings_no_overlap is a GiST exclusion constraint on dress and date range, not a check in code that a new path can forget.

  6. 06

    Writing while the network is down goes through an outbox

    Five operations are queued locally — delivery, return, new booking, adding a dress, and an expense — with a run_once guard on the server so that a retry returns the same response instead of creating a second row.

  7. 07

    Conflicts are raised to the manager; the system does not decide

    The sync_conflicts table stores the rejected operation and displays it, because resolving a booking or a delivery automatically means money or a dress moving on a decision nobody made.

  8. 08

    A user guide inside the app, with a build-time guard

    The check:guide script fails the build if a screen under src/app/(app) has no section, a field has no explanation, or a link points at a page that does not exist.

  9. 09

    21 verification scripts, 16 of them behavioural

    They open a real browser, fill forms and submit them, sign in with real accounts and read from the production database — rather than reading code.

  10. 10

    An audit log over 12 tables, with one read policy and no delete policy

    Not even the admin can remove a row from it. Writing happens only from the security definer triggers.

  11. 11

    Arabic search normalisation in a stored, indexed column

    Migration 0050, so the different spellings of a name all reach the same client, and the index keeps it a search rather than a table scan.

  12. 12

    A manual backup as an Excel file the admin controls

    With a restore script that previews before it writes and refuses to run against a database that already holds data without --force.

  13. 13

    An internal error log in the database

    error_log is shown in settings instead of an external monitoring service. The logging path is deliberately built not to fail loudly, so it has an end-to-end check proving it still writes.

  14. 14

    Zero CI workflow files

    There is no CI configuration in the repository at all; the eight static checks run inside the deployment build command itself, so the build fails before it deploys.

How the work is done

Read the detailHide the detail6 points

Rules are enforced in the deepest layer we can reach

A rule that can be written as a constraint or a trigger in the database is written there, and the code above it translates the rejection message. Protection that lives only in the interface disappears with the first new path.

Guards are proven by behaviour

A check that reads the code and reports that the rule exists is not evidence that the rule works. That is why 16 of the 21 scripts run the system: a real browser, forms filled and submitted, sign-ins with real accounts, and queries against the production database. A check that says fine while checking nothing is worse than no check.

Verification in two stages

The verify command (8 steps) asks whether the code is written correctly — local, fast, and it touches no network. The verify:all command (23 steps) asks whether the system actually works. The first one runs inside the build command itself, so a deploy cannot pass on code that breaks one of the project rules.

Every file explains why, not what

The project carries roughly 27,000 lines of Arabic comments inside the source. Every file and every migration opens with the reason it exists, the trade-off that was accepted, and the rollback scenario. A decision whose reason is forgotten gets reverted.

Migrations verify themselves

70 numbered files; 23 of them carry a self-check block that runs with the migration, 20 of those raise an error if the result is not what was expected, and 29 declare explicitly that they are safe to run more than once. They are applied from the command line through the database's management API.

The guide is part of the definition of done

Any change to a screen or a field updates its section in the user guide in the same commit — and the check:guide script is what catches the forgetting.

Arabic-first and responsive

Arabic first, not translated Arabic. The interface is entirely Arabic and right-to-left, with the sidebar on the right. There is no second language and no switch.

Read the detailHide the detail6 points
  • Every string lives in one place. Any text shown to the user lives in src/lib/strings, one file per screen; Arabic written directly inside a component is forbidden and the check:rules script enforces it. Measured: 5,711 Arabic strings across 41 files, and only 197 Arabic strings outside them — of which just 6 are in .tsx files, the rest being error messages in Server Actions and backup column names.
  • Western digits everywhere — a size reads 45, a dress code reads #DR-101, and dates and amounts render as 20 يوليو 2026 and 3,000 ج.م. All formatting comes from src/lib/format.ts, with no hand formatting, so the output stays identical between the server and the browser.
  • The time zone is written explicitly: a single TIME_ZONE constant in the display layer, and now() at that same time zone inside the database functions, across 18 migration files. Today is one number on the screen and in the database, not two depending on where the server sits.
  • Arabic search normalisation handles alef maqsura, taa marbuta, hamza, diacritics, tatweel and Eastern Arabic numerals — for names only. Dress codes, booking codes and mobile numbers are searched as raw text.
  • check:mobile opens every screen at 390px in a real browser and confirms: no horizontal scroll, every touch target at least 44px, input font at least 16px (below that iOS zooms), and the bottom tab bar not covering the end of the content.
  • check:responsive walks a matrix: every screen by every width, from 320 up to 820 and back down, across Chromium and WebKit.

Home on a phone screen

1 / 4

Access and traceability

Read the detailHide the detail

Two roles: admin and staff, and the difference is not cosmetic. Dresses, bookings, clients, deposits and expenses are open to both. Dress purchase prices, team salaries, the financial and profit reports, permanent deletion and settings are admin only. A staff member sees her own attendance record and cannot approve it herself, cannot edit a record after it has been approved, and cannot attribute an expense or a booking to somebody else — created_by is fixed from the session by a trigger.

Three layers on every protected route: proxy.ts, then the permission check on the server inside the page, then row-level security in the database. These layers appear across 47 source files. A staff member sees 8 of the 10 screens: reports and settings are removed from the navigation and also return a ForbiddenBlock from the server, and the staff screen is a completely different screen by role rather than the same screen with hidden elements.

Forbidden data never reaches the device in the first place. The local cache runs on an allow list rather than a deny list: any query root not written in PERSISTED_ROOTS is not stored, and there are exceptions inside an allowed root too — clients is allowed, but clients.detail is not, because it carries the national ID and the address. The check:offline script enforces that list.

The audit log. Twelve z_audit_ triggers on twelve tables write into audit_log: the table, the row, the kind of movement, who did it via auth.uid(), and the fields that changed with their before and after values. Every row carries an identifying snapshot that makes it readable on its own — booking code, dress code, client name — because a line with no identity is worth nothing in an investigation. The table has exactly one SELECT policy: writing comes from the security definer trigger, and deletion is refused for every signed-in role.

The door itself. The first sign-in stops at a change-password screen (migration 0045), and 5 failed attempts lock the account for 5 minutes, with the duration doubling on each attempt after that up to 60 minutes. Deactivating a staff member (is_active = false) actually cuts her access to every table through is_active_member() — not a hide in the interface.

The screens

28 screens

All client, staff and financial data in the screenshots is generated demo data — no real client, no real employee and no real amount of money appears anywhere; the application, the code and the database structure are real, and the atelier's name is shown with its owner's permission.

Sign-in and home

Sign-in by mobile numberAccounts are admin-created, not self-signup
Home dashboard with today's numbersUrgent alerts are derived from booking dates, not typed in

The working day

Closing the day and cash-drawer movementDeposits kept apart from revenue and expenses
A full working day, top to bottomOne recording place for every movement in the day

Registers and tables

The dress inventory as cardsDress status is computed from its bookings, not stamped on it
The full dresses page with paginationPaging and counting happen server-side, not in the browser
Booking management with status chipsStatus counters partition the total, nothing counted twice
The bookings table with paging and notesSorting, filtering, and paging live in one shared component
The client register with measurementsA client's measurements live in her record, not on paper
Deposits, agreed against collectedAgreed, collected, and outstanding are three separate numbers
The complete deposits ledgerTotals are aggregated in the database, not in the browser
The same dresses in table modeThe same data in another mode with no new network request
Board view split by statusThe same smart table becomes columns by status

Detail views

Bulk selection on the bookings tableEvery bulk action sits behind an explicit confirmation bar
The client profile in a side panelThe profile opens over the table without leaving it
The dress card and its timelineReturn on investment computed from money actually collected

Reports

Financial reports over a date rangeNet profit is a visible subtraction, not a standalone number
The full report with its leaderboardsDemand analysed down to size and colour

The team

Team attendance and admin approvalLateness and overtime computed from punch time against the shift
A full month's record with its totalsAn approval decision is reversible, not final

Permissions

Reports refused for a staff accountRefused on the server before any query, not a hidden button
Settings refused, password change allowedThe refusal is scoped to admin things, not the whole page
The same home through staff eyesThe financial cards are not hidden — they are never fetched

Settings

Settings and the user listAn admin cannot touch another admin's account
Every setting on one screenOffline work is an explicit decision with a named operation list
The activity log filtered to bookingsA log with no delete policy — not even for the admin

In-app guide

The user guide and its section indexEvery field in the system is explained inside the system
The guide's sections from top to bottomOne guide section for every screen in the system

What enforcement changes

Without enforcementIn this system
Paid and remaining: a number typed by hand that can drift from the payment recordComputed from the payment record, and a direct write to it is reverted
Booking status: any code or query can write any statusThree legal transitions only, enforced on the table
The same dress for two periods: a check in code that a new path can forgetAn exclusion constraint in the database rejects the row
A payment larger than the total: accepted, and it shows up in the reportsRejected at insert time
A dress that is not ready yet can be booked for a date it is not ready forThe booking is rejected by the available_from check
The deposit at delivery can stay marked as returned while the dress is still outIt goes back to held automatically with the delivery
Salaries and purchase prices: a hidden column in the same tableA separate table with an admin policy, and it never downloads to the staff device
Who changed what: not recorded12 tables recorded with the actor and the before and after values, and the row cannot be removed
Search by name: one spelling does not find the other, producing a duplicate fileBoth spellings reach the same client
Working with the network down: the operation is lost or recorded twiceIt is stored and sent once, and anything rejected is raised to the manager
The guide drifts behind the screens quietlyThe build fails if a screen has no section
Backup: none, since the plan carries no automatic backupsAn Excel file the admin controls, verified row by row against the database

This table compares only what is visible in the software itself: the left column is the default behaviour of any system without the rule, the right column is the behaviour actually enforced here, and every line is verified in the code or against the production database. Beyond the software, the project owner reported on 30 July 2026 that 4 users are using the system — a reported figure, not a measured one. How the atelier worked before the system, and the results observed after it, are not measured and are deliberately not claimed here.

The numbers

Code size

total lines under src/
83,064
of them code lines (comments and blanks removed)
56,083
.ts and .tsx files (247 + 220)
467
components, none of them arrow functions
281
.tsx files of 220 that are Server Components
77
Server Actions across 16 files
58
custom hooks across 13 files
58
Arabic strings across 41 string files (1,807 screens · 3,685 guide)
5,711
pages
13
API routes
8
feature modules under src/features
15

Measured on 30 July 2026. Every figure has the command that produced it recorded in case-study/metrics.md.

Database

SQL migration files
70
lines of SQL in them
14,887
migrations carrying a self-check block
23
migrations declared safe to run more than once
29
tables
21
views
3
enums
8
functions written by the project migrations
61
triggers
32
row-level security policies
53
tables with row-level security
21/21
tables covered by an audit trigger
12
legal booking status transitions
3
operation kinds queued while offline
5

Read from the production database on 30 July 2026 with SELECT queries only.

Verification

verification scripts (19 check:* + audit:rls + smoke)
21
of them behavioural — they run the system
16
lines of verification and tooling code
14,211
steps in the verify command
8
steps in the verify:all command
23
CI workflow files — the static checks run in the build itself
0

Build history

commits
226
calendar days, 22 to 30 July 2026
9
lines added in total
115,739
lines deleted in total
12,185

Demo data volume

audit log
1,516
daily expenses
634
payments
610
attendance
520
bookings, all of them SEED-%
306
security deposits
306
notifications
120
dresses
75
clients
64
accounts
28
salary payments
15
rows in total
4,194

This is demo data, not commercial activity. All 306 bookings in the database are seed rows marked SEED-%, and there are zero real bookings. These figures describe the data volume the system was exercised against — that the screens, the reports and the indexes actually ran at this size — not the activity of an atelier.