Leganews Pro
A versioned Congolese legal corpus with editorial review, search, monitoring, and subscription access
Role
Co-founder and CTO
A versioned Congolese legal corpus with editorial review, search, monitoring, and subscription access
Co-founder and CTO
2025
Congolese news collection with a Rust crawler, a TanStack Start dashboard, and searchable article data
Send the goal, stack, and timeline. I'll reply with a next step.
Leganews began in 2019 with a recurring problem for legal professionals in the Democratic Republic of Congo: finding a text often meant searching official publications, institutional websites, scanned files and private archives. Finding a copy was only the first step. The reader still needed its source, the relevant version and the texts connected to it.
As co-founder and CTO, I work on the product direction and the software that supports this research. The first production application already handled legal content, subscriptions, payments, favorites and search history. The current platform has to preserve that work while supporting a more precise corpus model, editorial review, monitoring and document comparison.
The latest changes reach beyond the user interface. Search now uses Meilisearch with a PostgreSQL fallback. Alerts cover both published versions and added attachments. Sessions survive application restarts through Redis, and releases carry explicit records of the images being deployed.
The first platform used Symfony, Twig, Symfony UX and MariaDB. Its Content model held one current body alongside publication metadata, access flags and counters. That was useful for publishing a record, but a legal text needed a stable identity even when its wording changed or a new file was added.
I separated those responsibilities into four concepts:
| Concept in the code | Responsibility |
|---|---|
| Work | The legal text's stable identity, reference and metadata, whether a normative act or a case-law decision |
| Expression | A particular version of that Work, with its dates, validity period and editorial state |
| Expression Unit | An article, chapter, paragraph or other structural part within a version |
| Manifestation | A rendition used for reading or export, such as HTML or PDF |
A supporting attachment belongs to the Work and remains distinct from a version of its content. Classifications, subjects, jurisdictions, types and sources also have separate meanings. A legal topic and the institution that published a text should not compete for the same category field.
This is the implemented model. A Work keeps its page while readers select an Expression. An editor can prepare a new draft without removing the previously published version. The recorded dates, sources and relations help readers assess the text; publication in Leganews does not certify that it is currently in force or that the corpus is exhaustive.
The administration application includes a Lexical editor for legal text. On save, the backend rewrites editor references to stable identifiers, validates the structure for the Work kind, and stores both the editor document and the Expression Unit tree in one transaction. Units have parent identifiers, ordered positions and materialized paths. The body is the single root.
Keeping the editor document and the structured units serves different needs. Editors need formatting and editing state. Reading, citations, exports and comparisons need identifiable sections of a legal version. When an editor starts a continuing Expression, the platform can clone the existing structure and rewrite internal references so the new version does not point back to the old version's units.
The editorial workflow separates drafting, review, approval, publication and withdrawal. A reviewer can request changes with a comment. The Work's public editorial state is derived from its Expressions, so an unpublished revision does not hide an older published text.
Publication triggers downstream work such as summary preparation, search projection updates, version comparisons and alert matching. Saving structured citations can also project relations between Works. These relations distinguish a citation from an amendment, repeal or implementing text. Automated relation suggestions retain their own review state rather than becoming confirmed relations by default.
For summaries, the platform keeps manual, generated and extracted provenance. The Symfony AI adapter supports configured OpenAI, Gemini or Ollama providers and bounds the source text sent to the model. It asks for a short factual French summary without interpretation. Provider failure can fall back to an extracted summary, although a prompt and provenance label do not establish the accuracy of generated text.
The public discovery workflow belongs to Corpus. Its search implementation still uses adapters under Monitoring, where recent searches and keyword suggestions also live. That distinction matters when describing the code: the product responsibility has become clearer without every implementation class moving at once.
Meilisearch now handles primary Work search. Users can combine text queries with legal metadata filters, choose a search scope and inspect highlighted results. PostgreSQL retains durable search documents and a separate search implementation. If the Meilisearch request fails, the adapter logs the failure and queries PostgreSQL instead; a successful but stale Meilisearch response does not trigger that fallback.
Search projection updates write the PostgreSQL document first, then attempt Meilisearch. A Meilisearch failure is logged without discarding the database projection. Reindexing can rebuild the external index from those documents. This adds indexing lag and repair work to the operating model, and the ordinary Meilisearch rebuild clears the index before loading batches. It should not be described as an uninterrupted index swap.
I also separated reusable cached data from user-specific reads. Reference lists, public directories, aggregate counters and rendered Expressions can be cached in Redis. Rendition cache keys include content revisions, so an edited version selects a different entry. Access decisions and complete personalized Work responses remain uncached. Redis cache failures can fall back to recomputation; Redis sessions and queues have different availability requirements.
A research session rarely ends with one search. Users can save Works in named collections, called Dossiers in the French interface, keep personal notes, and return through search, consultation or download history. Each history can be cleared independently. Removing personal download history does not reduce the Work's aggregate lifetime download count.
Monitoring distinguishes an Alert from an Alert Notification. The Alert records what a user follows: a classification, a subject or one Work. The notification records a matching event. Publishing an Expression and adding a Work attachment now feed the same notification service.
The matcher selects eligible subscribers in bounded pages, then loads their matching alerts. It considers when the alert was created, excludes events already notified and resolves overlapping targets through domain rules. Database uniqueness provides a final duplicate guard. The delivery service processes 100 recipients per page by default, stores notifications and queues realtime delivery through Mercure. Email preferences are applied separately, and an email failure is logged without stopping the remaining recipients on that page.
The weekly monitoring digest is a separate catch-up mechanism for eligible users who have not opted out. Its purpose and preferences differ from an individual alert notification and from general communications newsletters.
Compare supports two related workflows: comparing published Expressions of a Work and comparing two PDF or DOCX uploads. The upload workflow requires an active subscription and permits one processing comparison per user at a time. The comparison record expires after seven days.
A worker extracts text, normalizes it and generates a diff. Identical file checksums can skip extraction. Empty extracted text and oversized diffs produce explicit failures; temporary uploads are removed after processing succeeds or fails. Mercure updates and completion or failure emails keep the user informed after the request has returned.
The reader presents differences side by side, with line numbers, filenames and printing support. This compares extracted text. The PDF adapter uses pdftotext, so an image-only scan without extractable text needs another preparation step. A displayed wording change also leaves its legal significance for the reader to assess.
Billing records products, prices, orders, transactions and provider events. Access determines subscriptions, organization membership, seats and direct grants to a Work. I keep these responsibilities separate because a confirmed payment still has to produce the correct entitlement for the correct person or organization.
FlexPay supports the mobile-money and card checkout paths, with CDF and USD represented by money values in the model. Provider events are fingerprinted so repeated callbacks can be recognized. Fulfillment locks the order, checks its items, delegates entitlement creation or renewal to Access, and marks the order fulfilled only after the items succeed. Existing fulfillment is detected before creating another entitlement.
The important failure case is a paid order whose access has not yet been fulfilled. Payment state and access state remain separately inspectable, and fulfillment has an explicit command. Retrying it must preserve the existing order and avoid duplicate subscriptions or grants.
Access also records consultation and download limit exceedances in a dedicated abuse case. The evidence stays separate from Identity's account state. This lets the system retain warning history and the reason for an enforcement decision without turning the User object into a collection of unrelated counters.
The main and administration applications use React, TanStack Start, TanStack Router and TanStack Query in a Bun workspace. Shared packages provide API contracts, authentication, forms, the legal reader, editor and UI components. Zod schemas define the TypeScript transport contracts. Symfony remains responsible for authorization and business decisions.
The main application combines public discovery and an authenticated research workspace. The admin application supports editorial and commercial operations. Each has its own server-side session and same-origin API proxy. The browser receives an HttpOnly session cookie; Symfony access and refresh tokens stay in Redis-backed server storage.
Concurrent requests within an application process share an in-flight token refresh for the same session. Transient upstream failures are distinguished from rejected credentials, so an unavailable API need not be treated as a bad password or an invalid session. The proxy streams responses and bounds buffered request bodies and upstream waiting time.
The Symfony backend now has eight business contexts:
| Context | Owns |
|---|---|
| Corpus | Legal Works, versions, structure, editorial workflow and discovery |
| Monitoring | Research history, collections, notes, alerts and notifications |
| Identity | Accounts, authentication and security challenges |
| Access | Entitlements, organizations, memberships and access enforcement |
| Billing | Commercial catalog, orders and payment processing |
| Compare | Uploaded-document and Expression comparisons |
| Policy | Platform policy documents and their revisions |
| Reporting | Cross-context statistics and monthly reports |
Reporting was extracted from Shared. It reads other contexts' tables and enums without taking ownership of their business rules. That gives reporting a clear home, but table changes can still break its SQL; database-backed tests cover that dependency.
The API has separate command, query, deferred-message and event buses. Commands and worker messages normally run within Doctrine transactions. Domain events are delivered after the originating transaction commits, so a failed write does not announce a change that never happened.
Event reactions are best effort. If one fails after commit, the failure is logged and other reactions can proceed; the original command is not rolled back. That avoids returning a misleading failure for an already saved change, but it also means critical follow-up work needs explicit recovery. This is not a guarantee that every event reaction will eventually complete.
An audit middleware reports domain events recorded by an aggregate but never released by its handler. Failure logging records the message, handler and correlation identifier once. Redis-backed interactive, notification and telemetry queues have separate retry settings and a failed-message transport. These records help distinguish a committed action from unfinished downstream work.
The legacy MariaDB importer now lives in a removable LegacyImport module. Ordered plans translate users, corpus records, billing, access and monitoring data into the PostgreSQL schema. They use the destination model's values and constraints, process bounded batches, and report inserted, skipped, rejected or failed rows.
The operational workflow starts with a checksummed legacy snapshot and separates capture from import. Import receipts and restartable finalization support retries, including search parity checks, attachment transfer, citation reconciliation and subscription synchronization. This workflow is implemented; its existence alone does not establish that the production migration is complete.
Deployment responsibilities are also separated. The infrastructure repository prepares Ubuntu hosts, Docker, Tailscale and Dokploy registration. The platform repository owns application images, migrations, release manifests and deployment checks. pctl provides local and CI task planning and environment contracts; GitHub Actions owns release publication and Dokploy activation.
The staging workflow runs for successful pushes to main. Production semantic-version tags require staging eligibility for the same commit. Release manifests record exact image digests, and readiness checks compare the running API commit with the deployment receipt. The development object store now uses Silo behind the S3-compatible interface, replacing the earlier MinIO image.
The current test suite goes beyond isolated domain objects. Behat scenarios across the business contexts now use Doctrine repositories, actual database constraints and the production command-bus middleware. External systems such as email, storage and payment providers can still be substituted in tests.
That change exposed concrete Identity defects: an expired security challenge could block a new one through a unique constraint, and an account lock intended after five failed logins occurred on the sixth. The implementation now retires the expired challenge and includes the current failure in the count. These are examples of behavior that the previous in-memory repositories did not reveal.
Functional tests exercise alert matching SQL, duplicate payment events, reporting queries and policy writes. Vitest covers shared web packages, while Playwright journeys use an isolated API, database and runtime services. The isolated environments make those journeys repeatable, while live provider behavior and deployment recovery still need operational checks.
The next evidence I want from the platform is operational: search relevance on reviewed legal queries, indexing delays, alert delivery failures, migration reconciliation and recovery of paid orders awaiting fulfillment. Those measurements would say more about the service than a count of implemented features.