Dalem Conecta
CRM for wholesale and retail travel agencies in Colombia, live in production.
Role: Full-stack design and development
- NestJS
- Prisma
- PostgreSQL
- Angular
- Ionic
- Capacitor

Problem
Travel agencies in Colombia often run their operation across spreadsheets, chats and the memory of whoever picks up the phone. A quote lives in one message, the sale in another file and the to-dos in someone’s head. When the team grows, or when the advisor who knew everything is on vacation, information gets lost and so do sales.
Tracking payments is another weak spot. A trip is paid in installments, sometimes in two currencies, and every payment has to be tied to the right sale. If that record lives in a loose spreadsheet, nobody knows for sure how much is still owed or when the next installment is due. And after the trip, after-sales is almost always forgotten: there is no survey, incidents go unrecorded, and the customer does not hear from the agency again until they need another trip.
There is also a case that almost no generic tool handles well: wholesalers and retailers working together. The wholesaler builds packages and the retailer sells them to their own customers, and between the two there is a flow of orders, seat holds and payments that is still coordinated by hand, through messages and phone calls.
I wanted a single place where an agency could run the whole trip, from the first quote to after-sales, without one agency ever being able to see another’s data. That last condition shaped almost every technical decision that follows.
Solution and key features
Dalem Conecta is a CRM built around that full cycle. An agency signs in from the browser or from the Android app and always works with the same data. These are the main pieces:
- Customers with data-processing consent (Habeas Data), accent-insensitive search, and lists with filters, sorting and pagination.
- Quotes with several line items, alternative options, totals per currency, statuses and automatic expiry.
- Sales with installments, payments with exchange rates, a passenger list and a to-do list that generates itself when the sale closes.
- A B2B module: the wholesaler publishes a catalog, the retailer links to it, and the package goes straight into the quote with a seat hold and payments between agencies.
- To-dos and deadline reminders, with a daily digest so nothing gets forgotten.
- Payables to external suppliers and documents attached to each sale.
- After-sales: a satisfaction survey, incident tracking and a follow-up after the trip.
- In-app and email notifications, with templates specific to each agency.
- Modules that are switched on per agency, so each one sees only what it uses.
Each agency has its own team with different roles, and an administrator can onboard new agencies. The product is in production and I keep improving it based on what real use teaches. Every architecture decision in the sections below came from a concrete problem that needed solving, so I tell them layer by layer: first the API, then the client, and finally the site that presents the product.
API
The API is built with NestJS and Prisma on top of PostgreSQL. I chose NestJS because its module structure forces order on the code from day one, which matters when a single person maintains the whole backend and has to come back to a module after months without touching it. Prisma gives me a typed schema and versioned migrations, so the data-access code stays short and readable.
The central decision is multi-tenancy. All agencies share one database, and the isolation between them is applied automatically. Every request carries the context of the agency that makes it, using nestjs-cls to hold it for the whole life of the request, and a Prisma extension adds the agency filter to every query without the developer having to remember to write it. That greatly reduces the risk of forgetting a filter in a new query, which is the classic mistake in this kind of system.
Because an automatic filter is still code, and code can fail, I added a second barrier inside the database itself: PostgreSQL row-level security (RLS). If a query ever slips past the application filter, the database refuses to return rows from another agency. These are two independent layers, and the redundancy is deliberate. For a product where one agency must never see another’s data, I am happy to pay the cost of maintaining both.
Authentication uses JWT with refresh tokens and per-agency roles. The access token is short-lived and the refresh token is rotated on every use, so a stolen token has a very narrow window. Roles separate what an administrator, a sales advisor and the operations team can do, and the system enforces those rules on the server, never only in the interface. There is also a limit on sign-in attempts and a separate case for the superadmin, who can switch agencies to give support.
Finally, the API publishes an OpenAPI specification that works as a contract. It is more than documentation: the typed client used by the interface comes from it, as I explain in the next section. Per-agency modules are controlled by a global guard, so a feature that is switched off for an agency does not respond even if someone knows the route.
Client
The client is built with Angular, Ionic and Capacitor. It is a single codebase for the web and for the Android app, and that is the decision that saves me the most time. One person maintains both clients, with the same screens and the same logic, and every improvement reaches both at once. Today the product runs in the browser and on Android, which is what the agencies need, and I would rather say so plainly than promise platforms that do not exist.
The price of that choice is that the app is not purely native. On very demanding screens I give up some smoothness, but in exchange I maintain far less code, and for a CRM made of forms, lists and dashboards that cost is barely noticeable.
The types and the HTTP client are generated from the API’s OpenAPI specification. That way the contract between client and server lives in one place, and an API change that breaks a screen is caught at compile time, not in production. The cost is an extra generation step in the workflow and the discipline of keeping the specification up to date, which I accept without hesitation.
Two features show how the work is split between client and server. The first is the quote PDF: it is generated in the browser, not on the server, which keeps load off the API and keeps the flow fast. I had to design it carefully so the document looks the same on any device, because the result depends on the advisor’s machine. The second is the WhatsApp templates: agencies already close their sales on that channel, so from the quote the advisor sends the PDF with a message built from a template, and can adjust it before sending.
That split follows a simple rule: the API returns raw data and the client handles presentation, meaning the texts, the currency and date formats, and the documents. The exception is email, which goes out from the server with templates specific to each agency.
Marketing website
The product also has a static website that presents it to interested agencies, at dalemconecta.com, separate from the application that lives at app.dalemconecta.com. I keep it apart on purpose: it is fast, it does not depend on the API, and its copy or design can change without touching the product. I designed and built it too, with the same goal of loading fast and being understood in a few seconds.
Architecture
Simplified diagram; internal details omitted for privacy.
- The web and Android client calls the API; all state lives on the server.
- The API stores data in PostgreSQL and filters every query by agency, with RLS as a second layer.
- Authentication uses JWT with refresh tokens and roles, and the API decides what each user can do.
- The API sends email and messages through external services; the marketing site is static and separate.
Technical decisions
Several agencies share a single database, with isolation applied automatically.
- Why
- Every query is filtered by agency without the developer having to remember it, and PostgreSQL row-level security acts as a second barrier if anything slips through.
- Trade-off
- A bug in the automatic filter would be more serious than with separate databases, so that isolation needs more rigorous testing.
One Angular codebase with Ionic and Capacitor for both the web app and Android.
- Why
- A single person maintains both clients with the same screens and the same logic, and every improvement reaches both at once.
- Trade-off
- The app is not purely native: I give up some smoothness on very demanding screens in exchange for far less code to maintain.
Client types are generated from the API's OpenAPI specification.
- Why
- The contract between client and server lives in one place, and an API change that breaks a screen is caught at compile time, not in production.
- Trade-off
- It adds a generation step to the workflow and means keeping the specification up to date.
The quote PDF is generated in the browser and sent through a WhatsApp template.
- Why
- Agencies already close their sales over WhatsApp, and generating the PDF on the client keeps load off the server and the flow fast.
- Trade-off
- The result depends on the advisor's device, so I had to design the PDF carefully to look the same everywhere.
Gallery
My role
I designed and built the whole product: the API, the web and Android client, and the marketing site. I made the architecture decisions, wrote the code and shipped the system to production. Today I maintain it and keep growing it based on what real usage shows.