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

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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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
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
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
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
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
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 screensAll 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
The working day
Registers and tables
Detail views
Reports
The team
Permissions
Settings
In-app guide
What enforcement changes
| Without enforcement | In this system |
|---|---|
| Paid and remaining: a number typed by hand that can drift from the payment record | Computed from the payment record, and a direct write to it is reverted |
| Booking status: any code or query can write any status | Three legal transitions only, enforced on the table |
| The same dress for two periods: a check in code that a new path can forget | An exclusion constraint in the database rejects the row |
| A payment larger than the total: accepted, and it shows up in the reports | Rejected at insert time |
| A dress that is not ready yet can be booked for a date it is not ready for | The booking is rejected by the available_from check |
| The deposit at delivery can stay marked as returned while the dress is still out | It goes back to held automatically with the delivery |
| Salaries and purchase prices: a hidden column in the same table | A separate table with an admin policy, and it never downloads to the staff device |
| Who changed what: not recorded | 12 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 file | Both spellings reach the same client |
| Working with the network down: the operation is lost or recorded twice | It is stored and sent once, and anything rejected is raised to the manager |
| The guide drifts behind the screens quietly | The build fails if a screen has no section |
| Backup: none, since the plan carries no automatic backups | An 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.




