Nutqi — Comparison with the previous build
Nutqi was built once before. This page compares that earlier build against the one delivered here, module by module, and records what we took from it. It is an assessment of engineering trade-offs, not a scorecard: the previous team solved some problems earlier and better than we did, and those solutions are being adopted rather than reinvented.
How this comparison was made#
The previous build ships two deployed pieces: an API written on Django REST Framework, and a web client written on Next.js. Both are publicly reachable, which is what made a factual comparison possible at all.
- The API described itself. It publishes a machine-readable
OpenAPI 3.0.3description and hosts a Swagger UI over it, so its surface could be read exactly rather than inferred from screens: 40 paths, 87 operations, 103 schemas (30GET, 22POST, 11PUT, 12PATCH, 12DELETE). - The client was driven, not guessed at. Its pages were navigated in a browser in both language editions, and the HTTP responses of its public URLs were read directly.
- Nothing private was touched. No account was created and no credential was used. Personal endpoints were confirmed to refuse unauthenticated callers with
401; only public reference data and public pages were read.
What the previous build does well#
Four things in that build are genuinely good engineering, and two of them are better than what we shipped.
- The authentication surface is complete and correctly closed. Bearer-token sign-in with explicit
login/refresh/verify/logoutroutes, and a full account-recovery set beside it: issue anOTP, check it, request a password reset, confirm it, set a new password. Personal endpoints return401to an anonymous caller; reference data is deliberately public. That is the right split, and it was verified rather than assumed. - Reference data is normalized and bilingual in the database itself. Countries, states and cities are their own tables, and each row carries an Arabic name and an English name (plus a phone code and a sort key). The country table alone is fully seeded — 246 rows, both spellings present. A child record carries the same pairing: first and last name stored twice, once per language.
- Professional credentials are modelled, not typed. Specializations and sub-specializations are tables; so are training and courses, university degrees, and the work-data record that binds a professional to a specialization, a country, a state and a city — all of them as references, never as free text. A per-specialization first-session price sits on the same record.
- Account types are explicit. Main user, parent, personal, doctor-or-specialist and center each have their own schema, so a payload never has to be read defensively to work out which kind of account it describes.
- List endpoints share one convention. Every paged collection accepts the same query parameters — page, page size,
search,ordering, and per-field filters including case-insensitive "contains" matching. A developer who learns one collection has learned all of them. - The product intent is visible in the client. The three booking modes (online, in person, consultation) and the filter set (specialty, state, city, free-text search) are the right controls for this product, and the centers and professionals listed are real seeded records rather than mock rows. Registration is a four-step wizard — account type, email and phone, details, phone verification — across five account types, with a country selector on the phone field and a Google sign-in option.
Coverage: what each API contains#
The two APIs are not the same size because they are not at the same stage. Theirs is an account, profile and reference-data layer: 40 paths. Ours is that layer plus the transactional half of the product: roughly 150 routes across seventeen modules.
The previous API covers locations, authentication and users, professional profile data (specializations, sub-specializations, training and courses, university degrees, work data), the guardian side (children and their documents) and per-user attachments, blocks and videos.
Present in the current build and absent from the previous one entirely:
- Booking — the slot grid, the booking lifecycle and the transitions between its states.
- Sessions, work schedules and days off.
- Payments — the wallet, the ledger and withdrawal requests.
- The therapy-plan and assessment engine — templates, assignments, autosave while a form is being filled, and results.
- Reviews.
- Centers — branches, staff, and the HR tables behind them: attendance, absences, overtime, penalties and requests.
- Jobs and applications.
- Notifications, the admin activation queue, and a health probe for the deployment.
The trade-off is legible: they invested in the foundation a marketplace stands on and modelled it carefully; we invested in the transactions that turn a directory into a working clinic. Each build is strongest exactly where the other is thinnest, which is why the adoption list below exists.
Product content and the specialty taxonomy#
The deployed client is running on template content that had not yet had its content pass. The hero still carries the starter theme's real-estate copy — a headline about discovering what is next, and a line inviting the visitor to find the home that matches their dreams. Center cards use surgical operating-theatre stock photography and professional cards use unrelated stock portraits.
The taxonomy is the substantive part. The specialty list in production is general medical: teeth, digestive system and endoscopy, psychiatric, rheumatism, bones, vascular surgery, ear nose and throat, weight loss and nutrition, pediatric surgery, kidney. There are no speech-therapy specialties in it at all. A visitor landing on that site cannot tell what the platform is for, and a guardian looking for speech therapy has no filter that would find one.
The current build is speech-first throughout: the session types are speech therapy, skills development, visual communication and consultation, and they are the same four values in the directory filter, the booking form, the session record and the specialist profile. That is a product decision, not a data-entry difference — but see the adoption list, because how they store a specialty is better than how we do.
The two language editions#
Their client gets the hard part right and the long tail wrong. Switching to Arabic flips the layout to right-to-left correctly — that is the part that is expensive to retrofit and it is done — and the form field labels are translated.
What stays English inside the Arabic edition: the navigation (home, about, contact), the sign-in and get-started buttons, the hero, the booking-mode tabs, the line above the results list, and the registration form's own labels (email, phone number, register, back). The result is an Arabic page a reader still has to read in two languages.
This is precisely the failure the current build spent a decision on. D-28 split the documentation into two complete editions rather than one mixed site, D-29 defined when "bilingual" is finished, and both are enforced mechanically: the build fails if English prose carries Arabic script, if Arabic prose carries an untranslated Latin word outside a code identifier, or if a chrome string exists in one language and not the other. Direction handling has its own regression tests, because the two bugs that made an Arabic page unreadable are easy to reintroduce.
Web fundamentals: metadata, crawling and headers#
These are the checks a client can run against a deployed site without any access, and they are worth stating plainly because they are cheap to fix and expensive to leave.
- Page metadata is the framework default. The document title in production is the starter template's title, and the description is the starter template's description. There is no canonical link, no
hreflangalternates pairing the two language editions, noog:titleorog:imagefor link previews, and no favicon or web-app-manifest link. - Three infrastructure URLs return the application instead of what they promise. Requests for
/robots.txt,/sitemap.xmland/manifest.webmanifesteach answer200with the full HTML application shell — about 2.3 MB apiece, with the requested path echoed into the document'slangattribute. The cause is narrow and fixable: any path containing a dot bypasses the locale middleware and falls through to the application. Unknown paths without a dot behave correctly and return a 7 KB404. The practical effect is that a crawler asking for crawl rules gets a 2.3 MB page that is not crawl rules. - Security headers are thin on the client and partial on the API. The client sends
HSTS— the hosting platform's default — and nothing else: noContent-Security-Policy,X-Frame-Options,X-Content-Type-Options,Referrer-PolicyorPermissions-Policy, and it advertises its framework in anx-powered-byheader. The API is better: it sendsX-Frame-Options: DENY,X-Content-Type-Options: nosniffand aReferrer-Policy, though withoutHSTS, aContent-Security-Policyor aPermissions-Policy.
For contrast, and as the standard we hold ourselves to (D-30): the current build serves an enforcing Content-Security-Policy with a fresh nonce per request, HSTS, Referrer-Policy, Permissions-Policy and the framework banner removed, on both the application and the API; every page carries its own canonical, both editions are paired with hreflang alternates plus an x-default, each locale and page renders its own link-preview card, and robots.txt and sitemap.xml are real generated documents.
What we are adopting#
Recorded as D-39. Four items are being taken into the build, and two more are on the table.
Normalized locations, with both spellings#
Their location tables are the single clearest thing to copy. We store governorate and city as free text on the profile, which produces two visible problems: a city entered in Arabic renders in Arabic on the English site, and directory filtering is string matching against whatever was typed. Their model — country, state and city as tables, each row carrying an Arabic name and an English name — fixes both at once: the portal picks the spelling for the reader's language, and the filter becomes an identity match on a reference instead of a text comparison.
A specialty taxonomy instead of free text#
The same argument, one level up. Their specialization and sub-specialization tables are real entities that a professional's work record points at. Ours is free text bridged to a fixed set of session types through a shared label map, which D-26 documented as the mechanism that flattens payloads — the bridge works, but it is a workaround, not a model: it cannot express a sub-specialty, and it cannot be extended without editing code. A bilingual specialty and sub-specialty table, with the four current session types as its seed rows, replaces the bridge with a model.
An availability check before the form is submitted#
Their registration flow can ask whether an email or a phone number is already taken before the account is submitted. Ours discovers a duplicate the way most builds do — the user completes the form, presses submit and receives a conflict. Theirs is a better shape for a four-step wizard, where the duplicate is discovered on the step that created it rather than at the end.
Search and ordering on the paged collections#
The collections that already page in our API gain the two generic parameters theirs has everywhere: a free-text search and an ordering field. They cost little, they compose with the filters already in place, and they mean a portal screen can add a sort control without an API change.
Under consideration: a first-session price, and bearer tokens#
Two items are recorded but not scheduled. Their model puts the first-session price on the professional's specialization record, so a professional who works across two specialties can price them differently — worth doing if the product ever needs it, and cheap to add once the specialty table exists. And their bearer-token authentication is the natural fit for a mobile client; our cookie session is the right default for a browser, so the question is whether to offer both, not which to replace.
What we are deliberately not adopting#
Two of their conventions are reasonable choices that conflict with decisions already made and tested here. Changing course on either would cost more than it returns.
A pagination envelope on every collection#
Their API wraps every list in an envelope carrying a count, next and previous links, the rows and a message. It is consistent, and consistency has real value. Our contract goes the other way on purpose: a collection returns a flat array unless the screen reading it genuinely pages, in which case it returns an explicit paged shape. D-34 is why. An earlier pass had left tolerance in the clients — helpers that accepted either an array or an envelope — and that tolerance let endpoints drift silently: a screen that received the wrong shape rendered nothing instead of failing. Removing the tolerance is what surfaced 32 mismatched read paths. Re-introducing a universal envelope would re-introduce the ambiguity those clients were hardened against.
A display name composed on the server#
Their payloads carry names composed server-side. So did ours, and D-36 removed every one of them. The reason is simple and not a matter of taste: the server does not know the reader's language. A name assembled in the API pins the English site to Arabic names and the Arabic site to English ones, whichever way the fallback happens to be written. Our payloads now send the spellings that exist and let the portal choose per locale — the same rule that made the English edition actually read as English.
One thing they publish that we do not#
Their API publishes its OpenAPI description at a public URL, with a Swagger UI over it. That is a real advantage and it should be said plainly: any developer integrating with that API can read its exact surface in a browser, generate a typed client from it, and diff two versions of the contract mechanically. It is also what let this comparison be exact instead of approximate.
Ours generates the same kind of description, but the documentation UI is disabled in production by default — a deliberate hardening choice from D-30, since an interactive console on a production API is an attack surface. The right resolution is not to choose between the two: publish a sanitised, read-only schema document for integrators, and keep the interactive console off in production. That is recorded in D-39 as the follow-up.
One presentation detail worth carrying over as a caution rather than a practice: their published description still carries the generator's placeholder title, description and version. A schema is a client-facing document, and it should be named like one.
Where this leaves the project#
The previous build is a well-modelled account, profile and reference-data layer with a sound authentication posture and a self-describing API, carrying a client that had not yet had its content, metadata or bilingual pass. The current build is a complete product — booking, sessions, plans and assessments, payments, reviews, centers and their HR, jobs, notifications and administration — with the bilingual and hardening work finished and gated by tests, but with a weaker data model in exactly the two places theirs is strongest.
That is why this page ends in an adoption list rather than a verdict. Locations and specialties become reference tables with both spellings; registration checks availability before it submits; the paged collections gain search and ordering; a sanitised schema gets published. Everything else stays as decided, and the reasons are on the record.