A portfolio case study focused on modernizing a small motel’s legacy management workflow into a more reliable, secure, and maintainable business system.
Most portfolio projects are CRUD applications wearing a nice coat of paint. They demo well, they show you can wire a form to a database, and they prove — at most — that you can follow a tutorial.
The Harbourview Motel Management project was built to prove something harder: that a small business’s paper-era operational process can be modernized into a real system without over-engineering it, without hiding behind a framework, and without skipping the parts of engineering that don’t show up in a screenshot.
This is the story of that project.
The scenario
Harbourview Inn & Suites is a fictional ~40-room motel used as the reference scenario. The front desk historically managed room availability, reservations, guest records, check-in/check-out, room condition, and reporting through a combination of paper logs, spreadsheets, and the memory of experienced staff.
That works — until it doesn’t.
- A front-desk employee calls in sick and their knowledge walks out the door.
- A guest disputes a charge three weeks later and there’s no record to reconstruct.
- A power interruption during check-in leaves the state unknown.
- The internet goes down and suddenly nobody can do anything.
The modernization engagement was scoped against those four failure modes specifically, not against a generic “build a motel app” brief.

Discovery before design
Before a single line of code was written, the engagement started with discovery. Who are the stakeholders? What does the front desk actually do all day? Where does information live? Where do processes break down?
The discovery phase covered twelve areas: stakeholders, front-desk workflows, management workflows, the reservation lifecycle, guest management, room management, reporting, pain points, security concerns, reliability concerns, backup concerns, and growth considerations.
The most important discipline in that phase is not assuming the technology answer. The goal is to understand the business in the business’s own terms — not to fit the business into a pre-chosen stack.
Every architectural decision downstream traces back to a discovery finding. That chain is what separates a consulting-style modernization engagement from a feature-driven CRUD app.
Here’s the chain for one decision:
| Stage | Content |
|---|---|
| Business problem | Staff must be able to continue working during temporary connectivity loss. |
| Resulting requirement | The application must support an offline-capable workflow. |
| Resulting architecture | Use a local cache and a synchronization queue. |
| Resulting implementation | Service Worker + local storage + queued operations + a synchronization API with idempotency keys. |
That four-step chain repeats for every meaningful decision in the system.
The seven architectural principles
The whole project rests on seven decisions. Every one of them traces to a discovery finding.
- Business problem first. No decision exists without a business problem behind it.
- Server-side authorization. The UI may hide buttons; the server is the security boundary.
- Offline-first by design. Connectivity loss is a normal operating condition, not an exception.
- Idempotent operations. Every queued operation carries a deterministic identifier that makes replays safe.
- Explicit rejection path. Invalid offline operations are rejected into a reviewable queue rather than looped or discarded.
- Observable by default. Every response carries a correlation ID; every privileged action is audited and the audit chain is verifiable.
- Recoverable by proof. A backup is not a control until a restore has been demonstrated.
Principles 3 through 7 are the ones that make this project more than a UI refresh. They’re also the ones a CRUD tutorial would omit entirely.
Offline-first, not offline-tolerant
The application is designed to keep working during temporary connectivity loss. The Service Worker caches the application shell; local storage holds session and cache information; and an offline queue holds operations that couldn’t reach the server.
Here’s the part that matters: the online path and the offline path go through the same pipeline.
When you’re online, your request hits the Node.js server directly. When you’re offline, your operation goes into a queue. When you reconnect, the sync API replays that queue through the exact same authentication, RBAC, validation, and business-service pipeline your online request would have used.
That’s not a small detail. It means the offline path can’t accidentally be more permissive than the online path. It means the audit log records offline actions the same way it records online ones. It means you don’t have two divergent business-logic paths that slowly drift apart.
There’s one operational caveat worth documenting explicitly: offline disables cross-device visibility, not local functionality. Every feature works with or without a network. The only thing the network adds is shared visibility between devices. When one tablet is offline, its changes are held locally until it reconnects — and then it merges into the shared state through a bidirectional reconciliation.
Idempotency: the part that makes retries safe
Without idempotency, a client that queues an operation and reconnects may create a duplicate. The client can’t distinguish “the request failed” from “the response was lost.” The server, without idempotency, can’t tell the second request from a legitimate new one.
The fix is an operation log keyed by a deterministic opId. When a request arrives, the server checks the log first. If the opId is present, it returns the recorded result instead of applying the operation again.
// server/services/sync.js — idempotent operation handling.
export async function applyOperation({ opId, type, payload, actor }) {
const existing = await ops.lookup(opId);
if (existing) {
return { status: 'ALREADY_APPLIED', result: existing.result };
}
const result = await db.transaction(async tx => {
const service = registry[type];
if (!service) throw new ValidationError(`Unknown operation: ${type}`);
const output = await service.apply(tx, payload, actor);
await ops.record(tx, { opId, type, actor, result: output });
return output;
});
return { status: 'APPLIED', result };
}
The opId is generated once, at the time the operation is first enqueued, and preserved across retries. If the client regenerated a UUID on retry, the server would see a new key and apply the operation twice — which is exactly the failure mode this design exists to prevent.
Rejection is a feature, not a failure
Not every queued operation should succeed. A reservation created offline may conflict with a reservation created online by a different employee during the same window. A check-in may reference a guest record that has since been deleted.
The sync API distinguishes three outcomes:
- APPLIED — the operation was applied successfully.
- ALREADY_APPLIED — the operation was previously applied; the result is returned.
- REJECTED — the operation is invalid against the current state.
Rejected operations are not silently discarded. They’re moved to a rejected queue with a reason, and the UI surfaces them for staff review.
This is what makes the synchronization model honest. A naive “retry until success” loop either blocks forever or applies conflicting operations. A realistic model separates “this should be retried” from “this should be reviewed by a human.”

Security that holds up when the UI isn’t looking
Three controls do the heavy lifting here.
Server-side RBAC
Authorization happens server-side. The UI may hide buttons for capabilities a user lacks, but hiding buttons is not security — a user who crafts a request directly would bypass a UI-only control.
Two roles are defined:
| Role | Capabilities |
|---|---|
| FRONT_DESK | Reservations, availability, guests, check-in/out, room condition |
| MANAGER | Everything FRONT_DESK can do, plus reports, audit trail, users, backups, settings, room blocking |
Every request passes through the same pipeline: authenticate → authorize → validate → service. If the authorization check fails, the attempt is recorded in the audit log with a DENIED result before the request is rejected. The system logs the attempt, not just the outcome.
Hash-chained audit log
Every privileged action is recorded in an append-only audit log. Each entry contains a hash of the previous entry, which forms a chain — any modification to an earlier entry invalidates every entry after it, and the integrity verifier detects the break.
The best way to understand this is to watch it fail:
- Perform a privileged action (e.g., manager creates a new user).
- View the resulting audit entry.
- Run “Verify audit chain” — chain passes.
- Alter a test audit record directly in the database.
- Run “Verify audit chain” again — chain fails, and the verifier identifies the first broken entry.
That demonstration is the whole point. “We have an audit log” is a claim. “We can prove the log hasn’t been tampered with” is a security property.
The prototype documents its own limitation honestly: hash chaining provides evidence of tampering within the chain, but an attacker with direct database write access could recompute the entire chain from the tamper point forward. For higher assurance, the chain tip would need to be periodically published to an external, write-once store. That’s documented as future work, not glossed over.

Strict Content Security Policy
The frontend serves a CSP that blocks inline JavaScript and restricts script sources to same-origin:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
A manual version of this control is “remember not to put user input into a script tag.” A CSP encodes that discipline as a machine-enforced rule. It’s the difference between a guideline and a control — and the prototype keeps a regression test that verifies inline scripts are actually blocked.
Backup as proof, not checkbox
Backups are treated as part of the application, not an operational afterthought. The process is:
- Snapshot — the SQLite database is copied using
VACUUM INTO, which produces a consistent, standalone copy without interrupting readers. - Manifest — a SHA-256 manifest is generated for the backup file.
- Store — the backup and its manifest are written to a configurable target directory.
- Verify — the manifest can be recomputed and compared on demand.
- Restore — the backup can be restored into a running instance with a documented sequence.
And then it’s demonstrated end-to-end:
- Generate backup.
- Verify the manifest.
- Alter or delete test data in the running instance.
- Restore from the verified backup.
- Confirm the restored state matches the backup point.
A backup that has never been restored is not evidence of a recovery capability — it’s evidence of a backup file. The distinction matters.

Zero third-party runtime dependencies
The prototype uses only:
- Node.js runtime
- ES modules for code organization
node:sqlitefor persistence- Native HTTP for the server
- Native crypto for hashing and tokens
- Vanilla JavaScript for the frontend
No Express. No React. No Next.js. No Prisma. No Sequelize. No Firebase. No Supabase.
This is documented as an architectural constraint with explicit tradeoffs, not presented as a universal best practice:
| Advantage | Disadvantage |
|---|---|
| Small dependency surface | More code must be implemented manually |
| Transparent implementation | Fewer mature abstractions |
| Easy deployment | More responsibility for routing and validation |
| Easy inspection | Less convenient than established frameworks |
| Fewer external runtime requirements | Production maintenance requires careful consideration |
| Useful learning and portfolio value | Slower feature delivery than a framework |
The point of the constraint is that every line of the request pipeline is inspectable. There’s no framework magic between the HTTP request and the business service — you can read the six stages end to end. For a self-contained, easy-to-demonstrate prototype, that transparency is worth the extra code.
A production deployment would likely reintroduce select libraries for routing, validation, and observability. The migration document explains how that transition would be made.
Traceability: how you know it works
A feature list can’t answer the question “How do you know this works?”
A traceability matrix can. It maps each requirement to the code that implements it, the test that verifies it, and the demonstration that shows it.
| Requirement | Implementation | Test | Demonstration |
|---|---|---|---|
| FR-01 Staff authentication | Auth middleware | Auth tests | Login demo |
| FR-05 Check-in | Check-in service | Reservation tests | Check-in demo |
| FR-14 Offline operation | Service Worker + queue | Sync tests | Offline demo |
| NFR-01 Server-side RBAC | RBAC middleware | RBAC tests | Unauthorized action demo |
| NFR-02 Audit logging | Hash-chain service | Audit tests | Tamper demo |
| NFR-04 Idempotency | Operation log | Sync tests | Duplicate replay demo |
| NFR-06 Backup verification | Manifest + verify | Backup tests | Restore demo |
| NFR-07 Strict CSP | Server header | CSP regression test | Inline-script block demo |
The prototype reports 59/59 tests passing — but the number isn’t the point. The point is that the tests are organized around the reliability and security properties that distinguish a business application from a CRUD exercise.
The migration path is part of the deliverable
A prototype that can’t plausibly become a production system is a demo. A prototype that explicitly documents how it would become a production system is a modernization strategy.
The migration document covers the areas that would be easy to hand-wave:
- Schema compatibility (TEXT → VARCHAR, INTEGER → BIGINT/SERIAL, timestamp handling)
- Data types, indexes, constraints, transactions
- SQLite’s single-writer model vs. PostgreSQL’s MVCC
- ID generation strategy
- Migration scripts and rollback procedures
- Backup differences (
VACUUM INTOvs.pg_dump/ WAL archiving) - Running the same test suite against PostgreSQL
The migration document is what makes this a systems modernization case study rather than a motel demo.
What this project demonstrates
The Harbourview Motel Management engagement is a working example of seven architectural decisions in practice:
- Business problem first — every decision traces to a discovery finding.
- Server-side authorization — the UI hides buttons; the server enforces access.
- Offline-first by design — connectivity loss is a normal condition, not an exception.
- Idempotent operations — replays are safe by construction.
- Explicit rejection path — conflicts go to a reviewable queue.
- Observable by default — every response carries a correlation ID; every privileged action is audited and the audit chain is verifiable.
- Recoverable by proof — a backup counts only after a restore has been demonstrated.
Together with the HVAC Service Integration Hub (a companion case study), Harbourview covers one half of the modernization problem: replacing what no longer works with a reliable, secure, inspectable application. The HVAC project covers the other half — orchestrating what already does across system boundaries.
Screenshots
Every screen shares the same offline queue, the same request pipeline, and the same audit chain — because consistency across a system is not a feature you add at the end, it’s a property you design in from the beginning.





