← Home · All projects

Residential Client Portal

Replacing spreadsheets and 11 SharePoint lists with a single system of record for residential client management

22+ weeks · 80+ working sessions

Blazor Server.NETSQL ServerField-Level Security
1 / 1

We built a residential client management portal to replace a patchwork of spreadsheets and 11 separate SharePoint lists that a residential program team had been using to track clients, housing assignments, financial records, and regulatory recertification. Before it existed, the same client record lived in several disconnected places at once, with no reliable way to confirm any two of them agreed, and recertification deadlines for subsidized housing were tracked manually against a moving eligibility window, where a missed deadline carried real regulatory and funding consequences. The application runs on Blazor Server and integrates directly with the organization’s clinical record system, pulling in client and admission data automatically.

I built this one alone, deliberately. The development team was fully booked building an integration for a county payer over HL7, the healthcare data-exchange standard, with a hard deadline, and this need would have sat in a queue behind it, so I took it on to test whether AI-assisted work could carry a real production application. My last hands-on web programming was roughly 20 years back, and everything since has been reporting, data architecture, and databases, so without an agentic coding tool there would have been no application. That is what makes it a fair test of the tooling, and the fuller story of why a department head chose to write software again is in the journey.

We started with the smallest possible slice, a database and a single working page, and let real usage drive what came next. One design principle shaped almost every feature: nothing arriving from the clinical record system takes effect on its own. Each incoming change waits for staff review, because applying it blindly could wipe out a correction someone had just typed in by hand. Access control runs through roles layered with field-level permissions, configured through an admin screen and never hardcoded per page.

The system is now the operational system of record for the entire program, in production since an early deployment in the project’s first weeks, with 80 working sessions of development since. It has since been through a requirements-traceability review and a separate independent code review, and the test suite built in response to what they found now stands at 213 tests, all passing. The next planned phase migrates the remaining scheduled jobs to the shared Job Scheduler used across other internal applications.

The technical detail

This portal is the system of record for a residential program: client records, housing and bed management, financial tracking, regulatory recertification, and case management in one Blazor Server application, integrated with the organization’s clinical record system. It replaced spreadsheets and 11 separate SharePoint lists that could not be made to agree with each other.

Why I built this one alone

Our main development team had its hands full: an HL7 integration with a county payer, in the data-exchange standard healthcare systems use to talk to each other, exacting work against an external deadline with no slack in it. The residential program’s need was real, and it would otherwise have had to wait its turn. I therefore took it on myself, deliberately, to prove or disprove that AI-assisted work could deliver a genuine production application rather than a demonstration.

There were two more reasons, told in full in the journey: settling whether the industry’s “vibe coding” claims had substance, since I lead a department that would live with the answer either way, and the older pull of watching something I built come alive again.

I should be direct about my own starting position. My last stretch as a web programmer ended roughly two decades ago, and the years since went to reporting, data architecture, and database work. I can read code, reason about architecture, and review a design, but hand-writing a modern web application was not realistically within my reach. Without an agentic coding tool there would be no application, and I state that as fact rather than modesty. It is also what qualifies this project to measure the tooling instead of measuring how much faster a working developer can go.

Choosing the tool before starting

We did not default to a tool. Before this project began, we compared the options available to us: Microsoft Copilot, the chat assistant on Windows that I had already used for over a year, Claude, and OpenAI’s tooling.

Copilot had earned real credit in that time, and its limits were clear from use, not from reading about it. It was a chat window, which meant it could not read the codebase, could not compile or run anything, and kept nothing between sessions. Every line of code passed through me by hand, from paste to copy to editor to build.

What this project needed was something that could work across many files, run a build, and respond to what broke without me acting as the courier.

We selected Claude, and the timing lines up almost exactly with this project’s start. Applying a comparison before adoption is the same discipline that later produced the On-Premises AI Evaluation of 16 local models: measure the options against the actual work instead of adopting the one with the most attention.

Both tools, doing different jobs

Selecting Claude for the build did not mean abandoning Copilot. It meant right-sizing it, and the resulting division of labor held for the whole project.

Claude Code did the implementation: the application code, the database work, the integrations. Copilot stayed in the role it had already proven good at, which was everything written for people, not for a compiler.

That covered three things concretely. The first was status communication to leadership, where the useful work was expressing what had progressed and what was blocked, clearly enough that the next review could be scheduled against real progress, not optimism. The second was structuring requirements gathered from the residential team, particularly the notification rules behind the two compliance workflows, turning a set of conversations into stated rules that could be built against. Most of those requirements came out of what the residential team had already written: their Teams threads and email replies, distilled into stated rules. That was part of the test, whether requirements could be drawn from how people write to each other instead of from another round of meetings. The third was rewriting technical explanations for non-technical audiences, which for a project with this many stakeholders was constant.

That division illustrates the same point the local model evaluation later reached at greater cost: the right question is not which tool is best, it is which tool for which task.

How my working method changed as trust was established

This is the part of the project I would most want another person in my position to read, because the method at the end looks nothing like the method at the start, and the change was gradual, with no single decision behind it.

In the beginning the arrangement looked conventional: the assistant wrote the code and the programmer checked it. Claude Code produced the code, I read it as carefully as I was able, and then I carried each cycle through Visual Studio myself to compile, publish, and test. The iterations dragged, and a good share of every cycle went to me checking work I no longer had the current expertise to check quickly.

Trust moved one level up only as the evidence for it arrived. Claude Code took over writing, compiling, building, and publishing, not on faith but on the record of where mistakes happened. Reading every line was catching almost nothing that a compile plus a genuine test did not surface sooner, while it consumed hours that belonged to the questions no one else could answer.

What stayed with me was the judgment work, everything that needed knowledge of the problem rather than the code. That work meant exercising real features the way staff use them, checking boundary conditions, which is where most of this project’s genuine defects turned up, shaping with the analysts and the project manager what the application should do in the first place, and calling something wrong even though it ran.

Across this project I filled, in effect, four seats at once: developer, architect, product manager, and database administrator, while Claude produced most of the implementation. Both facts hold at the same time.

The product-manager half of that was not nominal. It meant gathering notification rules from the residential team and turning conversations into stated requirements, running a review cadence with the executive sponsor where the next meeting was scheduled against actual progress, and translating between what the application did technically and what it meant operationally for the people using it. On a project where implementation is largely delegated, that work is not overhead around the build. It is most of the remaining job.

What this did not speed up

An honest counterweight, and the thing I would say first if someone concluded from this site that AI-assisted development compresses delivery timelines generally.

In an organization this size, about a year passes before an operational team fully relies on a new application, and this one is partway through that year. That is not because the code takes so long to write, but because a small team queues a long while to put anything into production, and that queue barely notices how the code was produced. The queue is made of review cycles, access and security approval, coordinating with people whose main job is not this project, testing against real workflows with the people who perform them, training, and waiting for the right moment in an operational calendar to change a system people depend on.

None of that got faster. AI assistance removed the constraint that would otherwise have prevented this application from existing at all, given who was available to build it. It did not remove the organizational constraints around putting software into production, and I would be suspicious of anyone claiming otherwise.

How the project grew, phase by phase

The earliest sessions built the database structure and imported the starting dataset directly from the SharePoint lists and the clinical record system, giving the project a real baseline to build against instead of starting from an empty schema. The application reached its first production deployment within the first week, intentionally early, so configuration problems specific to the actual hosting environment surfaced immediately instead of staying hidden until a later, larger launch.

An early architectural decision reframed what a “Site” meant in the data model, from a purely physical location to the actual fiscal billing unit, with physical attributes layered on top, not the reverse. That reframing took a full session to specify before any code was written, since it touched how fiscal codes, addresses, and bed availability all related to each other, and getting the foundation wrong would have been expensive to unwind later.

Role-based access control and field-level permissions came next, driven by a configurable table instead of hardcoded checks scattered through the codebase, so a single admin screen controls whether any given field is editable, visible, or hidden per role. Financial tracking, document attachments through the organization’s file-sharing platform, and a case management module for tracking client-related matters and issues were each added as their own phase, with the case management module later renamed and restructured once real usage showed the original terminology and structure didn’t fit how staff used it.

A recurring scheduled-job system went through a full migration partway through the project, from one job scheduling library to another, touching every recurring job in the application at once, HUD recertification reminders, inactivity follow-ups, error alerting, all cut over in a single coordinated session once every job had been reimplemented and verified, not run in parallel with the old system during a slower rollout.

Design decisions worth explaining

Data from the clinical record system is never applied automatically. Every field sourced from that system gets queued for staff review before it takes effect, never silently overwriting whatever a staff member had entered by hand. This mattered most for discharge dates: an automatic discharge could remove a client from active lists before anyone had reviewed whether it should take effect, so I deliberately excluded discharge dates from the auto-apply path, requiring an explicit staff acknowledgment through a visible banner before a discharge is considered final.

Impersonation needed hard guardrails, not just convention. An administrator can view the application as another role for support and testing, which is a useful capability, but also a real risk if left unguarded. I built the restriction directly into the impersonation logic itself instead of trusting people to use it responsibly: an admin cannot impersonate another admin, and cannot impersonate themselves, closing off the two ways the feature could otherwise be used to quietly assume more access than intended.

A three-tier deployment gate applies to every change, without exception. Every code change publishes to a development server first, waits for explicit sign-off after real testing, and only then goes to the remaining servers together. This was tested directly partway through the project, when a database column removal was prepared and a stricter reading of an existing deployment rule was applied without waiting for that first sign-off. The correction that followed was explicit: the three-tier gate is a hard requirement for every change, with no exception for schema changes that feel urgent to finish quickly.

Handling a source system that doesn’t always agree with itself

Timestamps were an early and recurring source of quiet bugs. The application originally converted timestamps to a specific local time zone in the C# code and in column defaults, which worked until a stored procedure was found writing several timestamps directly at the database level, bypassing both of those safeguards, since a raw database write doesn’t pass through a column default the same way an application insert does. The fix was a single shared time-zone-aware function used everywhere a timestamp gets written, application code and stored procedures alike, so no write path needs to individually remember to convert correctly.

A related class of bug came from a scheduled synchronization job that ran nightly against the clinical record system. At one point it was found to be silently overwriting housing occupancy data on every run, discarding notes staff had entered by hand earlier that same day. The fix narrowed what the nightly sync is allowed to touch, instead of letting it freely overwrite an entire table on every run.

Bugs and turning points along the way

Each of these produced a lasting rule, not a one-time fix.

The request intake feature, and a decision I reversed

The application includes a staff request intake system: anyone can submit an issue or enhancement request, an approver triages it, and approved items become real tickets in the organization’s service desk. The route there involved abandoning a plan I had already worked out in detail.

The plan I threw away

The original design was to self-host Plane, an open-source project management tool, on one of our SQL servers using WSL2 and Docker. I had the plan fully specified: 17 setup steps, a reboot window, a half-day infrastructure session, and ongoing maintenance responsibility thereafter.

The reason for choosing a self-hosted tool was data residency. Requests would contain internal detail and keeping them on our infrastructure felt like the responsible default.

Then I re-examined the premise instead of the plan, and it did not hold. The organization had already adopted Freshservice as its company-wide IT ticketing system, and in doing so had already accepted its cloud hosting for this category of data. The concern driving me toward a self-hosted tool had been resolved organizationally before I started, and I had not checked.

Once that was clear, the comparison was one-sided:

Self-hosted PlaneFreshservice
License and costNew tool to justifyAlready owned
Staff onboardingNew interface to learnAlready in daily use
InfrastructureWSL2, Docker, reboot window, ongoing upkeepNone, cloud-hosted
IntegrationREST APIREST API
Data residencyOn premisesCloud, already accepted org-wide

An entire infrastructure workstream disappeared, and the feature got better, not worse, because staff were already familiar with where the tickets would land.

The lesson I took is not about either product. It is that I had spent real effort designing a solution to a constraint that had already been lifted, and the only thing that surfaced it was going back to check why the constraint existed. That is now a habit: before building around a restriction, confirm the restriction is current.

Designed, stubbed, then activated

The integration could not be built when it was designed, because API access was disabled at the account level and enabling it required a request to the service desk administrator.

Instead of letting that block the feature, I split it along the dependency. The submission form, the triage interface with approve, park, and reject paths, the local status tracking, and the request history view were all built and shipped while the API remained unavailable. Three pieces were stubbed and documented as stubs: ticket creation on approval, the status sync back from the service desk, and the closure notification email.

That decomposition meant staff got a working intake and triage system immediately, with a documented list of what would light up when credentials arrived. When the API key did arrive, wiring the three stubbed pieces was a contained piece of work, not the start of the feature.

What the finished integration does

On approval, a ticket is created automatically in the service desk, with the submitter’s name and email and the approver’s name embedded in the description so the receiving technician has the full context without asking. A technician is assigned automatically. A recurring job slides the target completion date forward each week for anything still awaiting approval, so stale requests surface instead of silently aging.

The page shows a single unified queue combining locally submitted requests and tickets pulled live from the service desk, with a badge distinguishing which system each item came from. That was a deliberate choice over two separate lists, because an approver’s actual question is what needs attention, not which system something happens to live in.

Retrieving the right tickets initially filtered by the agent’s email address, which returned the wrong set. The working approach is a two-step lookup: resolve the email to a numeric agent identifier first, then query tickets by that identifier. An identifier that looks interchangeable with an email address in the documentation frequently is not.

Migrating the entire scheduled job system, in one cutover

A larger architectural change happened partway through this project, and it is the origin of a standing rule I now apply to every .NET project.

The application’s recurring jobs were originally built on Hangfire. Over a stretch of sessions I migrated every one of them to Quartz.NET and removed Hangfire, including dropping its database tables.

The choice was between staging the migration, running both schedulers in parallel while jobs moved across one at a time, and doing a single cutover once every job had been reimplemented and verified. I chose the single cutover. Two schedulers running concurrently against the same jobs is a class of problem I would rather not debug, and the verification work was the same either way.

The migration also surfaced a bug that only appears at runtime. The error digest job grouped log entries using a query shape the data access layer could not translate into SQL. It compiled cleanly and failed only when the job executed against real data. Rewriting the grouping to run in memory instead of at the database level resolved it, and the general lesson is that a query which compiles is not a query which runs.

A visible administrative view of every scheduled job, its next fire time, and a manual trigger button came out of the same work. Later, that pattern became its own application, described in the Job Scheduler project, so that jobs did not need to live inside whichever application happened to need them first.

A security review, and what it found

A full security sweep ran as its own session, not as a step inside feature work, which I think is the only way it happens.

The package scan came back clean, with no vulnerable dependencies. The review also confirmed no SQL injection exposure: application queries are parameterized throughout, the external clinical database connection uses static queries, and the directory lookup uses a safe identity resolution call, not a constructed filter.

Three findings were fixed and shipped in that session:

Two lower-severity findings were deliberately deferred to a dedicated session, not rushed, on the reasoning that each was broad in scope and attempting them immediately before a production push was the larger risk. Recording a deferred finding with a reason is better than quietly leaving it undocumented.

The requirements reviews and the tests they forced

Late in the project the application went through something larger than a security sweep: a requirements traceability review, run twice, followed by an independent review built from a different copy of the code. Together they are the most thorough examination anything I have built has received, and the honest version of what they found belongs here.

The reviews had a second purpose beyond the application. The same question that started this project, whether the tooling has substance, applies to review work as much as to building, and running an AI-built matrix against an AI-built application, then checking both by hand, shows where the tooling holds up and where it needs a person against it. The gaps below are the answer.

The first review was blind, built from the code alone with no access to the project’s own documentation: 146 requirements across 14 areas, of which 124 traced cleanly to implementing code, 15 partially, and 7 not at all. It also confirmed that zero automated tests existed anywhere in the application at that point. The second revision went deeper, 168 requirements across 13 areas with 154 traced, and for the first time tracked test coverage as its own axis alongside implementation.

The ranked findings are more instructive than the counts:

The credential and the export-scope bypass were ranked as the two to fix first. The deeper pass added subtler ones: a method that should return upcoming deadlines returns a hard-coded empty list, a notification “Send Now” button fires a job with no data map and reports success while doing nothing, one timestamp column is read as UTC in some code paths and as Eastern time in others, some audit rows compare against a literal empty old value and therefore fire on every save, 9 registered dependency interfaces have no members at all, and 2 scheduled jobs are invisible to the in-app scheduler screen, manageable only at the database level. A remediation backlog of 15 tickets came out of it, each with a file, a line, and a verification query.

The review also recorded what held up well: the conflict-resolution design that proposes changes instead of overwriting, state that is derived instead of stored redundantly, audit rows named by the screen that produced them, specific and named error messages on import failures, and 132 numbered, logged SQL scripts.

The independent review came at the code from the opposite direction, reading the live development branch directly. Its main product was 71 automated tests across 6 test files, written in direct response to the zero-tests finding. It also corrected the record in both directions: the project’s own documentation claimed 53 entity models where the code holds 84, described the role model as 6 roles where it is 7, and listed 2 worklist addresses that did not match the running application while missing one fully built page. The suite has grown since and now stands at 213 tests, all passing. Development and production are in sync as of the latest check, verified by matching git hash and matching numbered SQL script on both.

The most recent phase

The work since then has been the unglamorous kind that a system of record accumulates. The subsidized-housing recertification worklist moved from a calendar-year filter to a rolling 12-month window, and fixing it surfaced 2 real bugs on the way: 28 leftover blank placeholder rows were outranking real ones in the list, and a completion-status field was not deriving from its own completion date. Audit logging was added to 4 admin actions that had been changing data without leaving a record. A visibility-scope inconsistency was fixed across 4 worklists, which needed a new SQL view. And a foreign-key error on dissociating a site that carried rent and board history was resolved by letting that history survive independently, since deleting it or leaving the record half-removed were both worse.

An investigation into migrating this application’s remaining jobs to the shared Job Scheduler disproved a recorded concern about double-sent notifications and separately turned up two real problems: the scheduler process was being stopped by a 20-minute idle timeout up to several times a day, and a placeholder recipient address had been failing every renewal-reminder run outright. Both were fixed.

The project’s task list is also now mirrored to a monday.com board, through the sync built in the Shared AI Skills Library. The backlog file in the repository stays authoritative, and the board’s status flips the moment the real event happens: work starting, a fix publishing to the test environment, a release going live. That replaced end-of-session batch updates, and it means non-technical stakeholders watch progress without having to ask for it.

What the application looks like

Architecture alone undersells how much of the work is interface, so here is what is there.

The application opens on a dashboard scoped to the signed-in person’s role, so a director sees their own program, not everything. From there:

Several interface decisions came from real usage, not design intent. A shared filter bar exists because four list pages had each grown their own search-and-debounce logic. Negative financial amounts are flagged with both a red background and an icon, never color alone. A confirmation step intercepts editing a closed case matter, inline, not as a modal, to match the existing delete-confirmation pattern.

The help documentation, and how it got built

The in-app help is one of the larger pieces of work in the project and the easiest to overlook, because documentation rarely shows up in an architecture diagram.

It runs to 28 sections with roughly 30 annotated screenshots, covering every page and every meaningful revealed state, with a sidebar navigation, a floating return-to-top control, and a help icon on every page in the application that deep-links into the relevant section instead of dumping the reader at the top.

It did not start there. The first version was a single static HTML file, which became difficult to maintain and did not participate in the application’s own navigation, authentication, or theming. Rebuilding it as a proper page inside the application took two consecutive working days and produced a set of problems:

The screenshot capture is scripted, not manual, driven by a reusable tool that walks the application, captures each page and each revealed state, and applies numbered callout annotations. That matters for maintenance: a help page with 30 hand-captured screenshots decays the moment the interface changes, and one that can be regenerated does not.

One process lesson came out of it that generalizes well beyond this project. A tool that reports what controls currently exist on a page cannot detect what has been removed. One help section documented a card with a numbered callout weeks after that card had been deleted. The fix was an explicit removal check: for each changed page, open the existing help content and verify every documented element still has a match in the current markup, dropping any that does not.

Timeline and effort

Two different numbers describe this project, and the gap between them is the most useful thing in this section.

Recorded development activity spans roughly 22 weeks, the longest of any project in this set. The project’s own worklog reached Session 80, with several working days covering more than one internally numbered session.

Reaching full operational reliance takes roughly a year, and this application is partway through it. It went into production early and has kept improving since, but the distance between working software and software a team depends on without thinking about it is measured in months of review, approval, real-workflow testing, training, and waiting for safe moments in an operational calendar.

Quoting only the first number would overstate what has happened. Quoting only the second would understate it. The honest version is that development stopped being the bottleneck, and everything that is not development did not get faster.

Where it stands now

The application has been in active production use since its first deployment in the project’s opening days, and every phase described above shipped and has been running in production since. Development is ongoing, with the next planned phase migrating the remaining scheduled jobs to shared scheduling infrastructure already serving other internal applications, so this system no longer needs to run its own copy of that machinery once the migration is verified.

Keep reading

Next project: Data Warehouse (Medallion Hub) →

Have a comment on this page? Send it to me →

Home · All projects