The Engineering Decisions Behind Monesize Engage: A CRM Built on Constraints

Monesize Engage is a multi-tenant sales engine and CRM platform. The product is designed to help organizations manage prospecting, outbound campaigns, pipeline management, and customer relationships from a single shared workspace. The goal from the beginning was not to build another feature-complete enterprise CRM that nobody uses. It was to build something lean, deliberate, and production-ready from the first release.
This post documents the engineering decisions made while building the backend. Not the happy path decisions where everything was obvious from the start, but the real ones where constraints forced choices, where the wrong thing was built first and had to be corrected, and where the architecture had to reflect the product's actual requirements rather than what felt elegant in isolation.
The backend is built on Node.js, TypeScript, Express, Prisma, and PostgreSQL. It runs on Monesize's virtual machines with Redis for job queuing. Every decision in this post traces back to one of those constraints.

The Multitenant Foundation

The most consequential architectural decision in a SaaS product is how you handle multi-tenancy. Get it wrong and every feature you build afterwards inherits the mistake.
There are three common approaches. The first is separate databases per tenant, which gives complete isolation and simple queries at the cost of being expensive to operate. The second is separate schemas per tenant within a single database, which offers better resource utilization and relative isolation but makes migrations complex. The third is a shared schema with a tenant identifier on every table, which is the cheapest to operate, the simplest to migrate, and requires disciplined application-level enforcement to avoid cross-tenant data leaks.
We chose shared schema. Engage is a free product. The economics of provisioning a database per tenant do not work when you cannot predict how many organizations will sign up or how large they will be. A single PostgreSQL instance with proper indexing can comfortably handle hundreds of tenants at the data volumes a CRM like Engage will see in its early years.
The consequence of this choice is that every table containing tenant-scoped data carries an organizationId column, and every query against those tables must include that column in the WHERE clause. Not just for correctness but for security. A query like WHERE id = requestedId is never sufficient when the resource is tenant-scoped. The correct query is always WHERE id = requestedId AND organizationId = authenticatedOrgId.
This discipline is enforced by convention rather than by the database itself. There is no row-level security policy at the PostgreSQL layer. The responsibility sits entirely in the application. Every service method that accesses tenant-scoped data accepts organizationId as an explicit parameter and includes it in every query. This is not optional. It is the baseline assumption the entire system is built on.
The auth middleware reinforces this. Every authenticated request attaches organizationId from the verified JWT to req.user. Service methods receive it as a parameter. They do not trust anything from the request body or query string to establish the tenant boundary.

Why User Identity Is Separate from Organization Membership

In most simpler systems, a user belongs to one organization and the two concepts are conflated into a single record. We separated them from the beginning because the product requirements demanded it.
A User record is a platform-level identity. It has an email address, a password hash, and authentication metadata. It does not belong to an organization. An OrganizationMember record is the join between a user and an organization. It carries the role, the membership status, the token version, and the member's notification preferences. A user can be a member of multiple organizations with different roles in each.
This separation has practical consequences throughout the product.
Login does not require an organization identifier anymore. When a user authenticates, they provide only their email and password. The system verifies their credentials, loads all their active memberships, and returns the appropriate response. If they belong to one organization, a session scoped to that organization is issued immediately. If they belong to multiple organizations, the system returns the list of their workspaces and a short-lived token. The frontend presents a workspace picker and the user selects which context they want to enter. If they belong to no organization, they are directed to create one or accept a pending invitation.
The original design required users to provide their organization slug at login. This was technically correct but was a bad user experience decision. A person should not need to memorize an identifier like acme-corp-4f2a to access their own workspace. The slug exists as a system identifier and appears in org profiles and configuration screens, but it should never be part of the authentication ceremony that users perform every day.
It also means invitations work cleanly. When an organization invites someone, the invitation is associated with an email address. If that address already has a platform identity, accepting the invitation creates a new OrganizationMember record linking the existing user to the org. If the address is new to the platform, accepting the invitation creates both the user and the membership atomically. Either way, the result is a verified account with an active org session.

Session Invalidation Without a Blocklist

JWTs are stateless by design. The standard problem is that once a JWT is issued, it cannot be revoked before it expires. If a user is removed from an organization, their token keeps working until its expiry. For a CRM where sales data is sensitive, this is unacceptable.
The common solution is a token blocklist stored in Redis. Every logout or revocation adds the token to a set with an expiry matching the token's remaining lifetime. Every authenticated request checks the blocklist. This works but it introduces a Redis dependency on the hot authentication path and the blocklist grows linearly with active sessions.
We used a different approach. Every OrganizationMember record has a tokenVersion integer that starts at zero. The JWT payload includes the token version at the time of issue. On every authenticated request, the middleware fetches the member record and compares the token's version against the current database value.
When a user is removed from an organization, the member's token version is incremented. Their existing JWT carries the old version. The next request fails the version check and is rejected immediately. The session is invalidated at the cost of one database read per authenticated request, which is a cost you are already paying for any meaningful authorization check anyway.
Logout works the same way. The logout handler increments the token version and clears the cookie. Even if the client somehow retains the old token, it will not pass validation.
This approach has no additional infrastructure dependency beyond the database that already exists, scales without growth in a separate store, and provides exactly the same instant invalidation guarantee as a blocklist.

The Email Infrastructure Problem

Campaign emails are one of the primary capabilities of Engage. Organizations configure their own outbound email infrastructure before sending any campaign. This is a tenant-level prerequisite enforced in code, not an optional advanced setting.
The reason is straightforward. If Engage provided a shared sending domain for all organizations, a single organization sending spam or triggering bounces would damage the deliverability reputation for every other organization on the platform. Each organization must own their sending reputation and their sending infrastructure.
The email layer is built around a provider interface rather than a direct SDK dependency. The interface defines two methods: send and verify. Every provider implements these two methods. The rest of the system speaks only to the interface.
This matters because email providers are not interchangeable at the configuration level. SMTP requires a host, port, username, and password. SendGrid requires an API key. Mailgun requires an API key, a domain, and optionally a region. Resend requires an API key. AWS SES requires a region and IAM credentials. Each has different authentication models, different rate limits, different error formats, and different webhook schemas. The provider factory maps the configured providerType to the correct adapter. Adding a new provider means writing one new adapter class. Nothing else changes.
The email configuration stores whatever credential shape is appropriate for the chosen provider, encrypted with AES-256-GCM before it touches the database. The encryptedConfig column stores the serialized JSON of the credentials. When the provider is needed, the service decrypts it, deserializes it, and passes it to the factory. The credentials never appear in API responses. They never appear in logs.
The encryption key lives in the environment and is never stored alongside the data it protects. Rotating it requires re-encrypting every encryptedConfig row, which is a defined maintenance operation rather than an implicit coupling buried in the data layer.
Before any campaign can be sent, the org's email configuration must be in a verified state. Verification triggers a real test send to a specified address using the configured provider. If the send succeeds, the configuration is marked verified. If it fails, the reason is stored and the configuration remains unverified. Campaign sending is gated on this verified status at the service layer and cannot be bypassed.

Async Campaign Processing

The first implementation of campaign sending was synchronous. The POST endpoint resolved all segment contacts, looped through them sequentially, awaited each individual SMTP call, and returned when the last one completed.
This works for a campaign with twenty contacts. It does not work for a campaign with five thousand contacts, and it definitely does not work for twenty organizations running campaigns simultaneously on the same server.
A single SMTP call takes somewhere between fifty and two hundred milliseconds depending on the provider and network conditions. Five thousand contacts at one hundred milliseconds each is eight minutes of continuous blocking execution. The HTTP connection will time out well before that. Even if it did not, the Node.js event loop would be occupied for those eight minutes handling one request, delaying every other request in the queue.
The solution is to decouple the trigger from the execution. The send endpoint validates the preconditions, enqueues a job, updates the campaign status to RUNNING, and returns 202 Accepted in under a hundred milliseconds. A background worker picks up the job and handles the actual sending.
We used BullMQ with Redis. BullMQ is a mature Node.js job queue library that uses Redis as its persistence and coordination layer. Upstash provides Redis as a managed service with a free tier appropriate for the early market-testing phase of the product.
The worker processes campaigns in batches of fifty recipients. Within each batch, all fifty sends are executed with Promise.allSettled, which means they run concurrently and individual send failures do not abort the batch. Between batches there is a two-hundred-millisecond delay to avoid overwhelming the provider's rate limits. After each batch the worker checks whether the campaign has been paused or cancelled, which allows mid-execution control without killing the worker process.
Campaign scheduling uses BullMQ's delayed job feature. If a campaign has a scheduledAt timestamp in the future, the job is enqueued with a delay calculated from the current time. BullMQ handles the timing in Redis and executes the job when the delay expires. Cancelling a scheduled campaign removes the delayed job from the queue before it fires.
Each execution step is written to a CampaignLog table. The log captures when the job started, how many recipients were resolved, batch progress, pause and resume events, and the final outcome. This gives organizations a full audit trail of what happened during a campaign send without requiring any external logging infrastructure.
The job uses BullMQ's deduplication feature via a deterministic job ID. Re-triggering a campaign that is already queued does not enqueue a second job. This prevents accidental double-sends from race conditions or user error.

Campaign Pause and Resume

Campaign pause and resume required careful thought about what state the system should be in when execution stops mid-flight.
Pausing sets the campaign status to PAUSED. The worker checks this status between batches. When it detects PAUSED, it logs the current progress and exits cleanly without marking the campaign as completed or failed. The job is consumed from the queue, but the campaign record retains its partial state in the database.
Resuming re-enqueues the send job. When the worker picks it up, it queries for all CampaignRecipient records that already have a SENT or SKIPPED status for this campaign. These contacts are excluded from the new execution. The worker only processes the contacts that have not yet been attempted. This means resume is safe and correct regardless of how far along the original execution was when it stopped.
This design avoids storing progress as a cursor on the campaign record itself, which would require updating the record atomically with each batch and introduce additional write contention. The recipient table already has the state needed to reconstruct where execution stopped.

Email Tracking Without a Third Party

Open tracking and click tracking are standard in outbound email tools. We implemented both at the application layer rather than delegating to a third-party analytics service.
Open tracking works by injecting a one-by-one transparent GIF into every campaign email before it is sent. The image URL encodes the recipient ID. When the email client loads the image, it makes a GET request to the tracking endpoint, which records the open event and increments the open count on the CampaignRecipient record, then returns the pixel immediately without any visible delay to the user.
Click tracking works by rewriting every anchor link in the email HTML before sending. Each link is replaced with a redirect URL that encodes the recipient ID and the destination URL in base64url format. When the recipient clicks, the tracker records the click, increments the click count, and issues a 302 redirect to the original destination.
Both tracking endpoints are mounted at /track rather than /api/v1. This matters for URL length. Tracking URLs are embedded in email bodies and shorter URLs are less likely to be truncated by email clients or broken by line-wrap rendering.
A few rules govern which links are rewritten. Unsubscribe links are never rewritten because they must function independently of the tracking infrastructure. Mailto and tel links are skipped because there is nothing to track. Already-tracked links are skipped to prevent double-wrapping on resends.
There is an important caveat around open tracking reliability. Apple Mail Privacy Protection, introduced in iOS 15 and macOS Monterey, pre-fetches tracking pixels before the user opens the email. This inflates open counts and makes them unreliable as a measure of actual reads. Click tracking does not have this problem because it only fires when the user actively interacts with a link. In practice, click data is the reliable signal and open data is at best indicative of general engagement.
Provider webhooks handle the delivery-level events that pixel tracking cannot capture. When Mailgun or SendGrid report a hard bounce, the backend marks the contact as SUPPRESSED and updates the recipient record. When they report an unsubscribe event, the contact's communication status is updated to UNSUBSCRIBED. Both are hard blocks on future campaign sends enforced at the worker level before any email is queued.

The Unsubscribe Token Design

The unsubscribe link in every campaign email must work without the recipient being logged in. It must also not expose raw database identifiers in a publicly accessible URL, since that would allow enumeration of contact IDs.
The token is a HMAC-SHA256 of the contact ID, keyed on the application's JWT secret. The URL carries both the base64url-encoded contact ID and the HMAC signature. On verification, the server re-derives the expected HMAC from the contact ID and compares it to the provided signature using crypto.timingSafeEqual to prevent timing attacks.
If the token is valid, the contact's communication status is set to UNSUBSCRIBED. All future campaign sends skip this contact regardless of which segment they appear in. The block is enforced at the worker level during contact resolution, not at the segment level, so it cannot be bypassed by re-segmenting.
The unsubscribe endpoint always returns 200 regardless of whether the contact was found or was already unsubscribed. This prevents an attacker from using differential responses to enumerate valid contact IDs.

GDPR as a First-Class Concern

Most systems implement GDPR compliance as an afterthought. The typical approach is to add a soft delete flag and call it done. A soft delete does not satisfy the right to erasure under Article 17. The data is still in the database in full. It has only been hidden from the UI.
Engage separates two distinct operations with different semantics and different access controls.
Soft delete is a UI-level action that marks a record as deleted and hides it from all list and get endpoints. The data is intact and recoverable at the database level. This is appropriate for accidental deletions or records the organization wants to archive.
Erasure is a different operation with different semantics. When a contact is erased, every personally identifiable field is overwritten. Name fields are replaced with the literal string [erased]. Email, phone, LinkedIn URL, location, and source fields are set to null. The contact's company association and owner relationship are cleared. All notes and tags on the record are permanently deleted. The contact's communication status is set to SUPPRESSED.
The record shell is retained. The ID remains in the database. Foreign key relationships to campaign recipients, activities, and follow-ups remain intact. This is intentional. Campaign statistics that reference the contact record would become inconsistent if the record were deleted. Historical counts and associations remain valid while all identifying information is permanently removed.
The erasure operation sets both a deletedAt and an erasedAt timestamp. The presence of erasedAt excludes the record from deduplication checks during CSV import. This prevents a re-import from inadvertently creating a new record for a contact whose data has been formally erased.
Erasure is restricted to the OWNER role. This is not just a role check. It reflects that erasure is an organizational decision with legal implications, not a routine data management task that any member should be able to perform.

Email Verification as a Security Gate

The initial implementation allowed users to register and immediately log in without verifying their email address. This is convenient during development but wrong for a product that sends outbound campaigns on behalf of organizations.
An unverified account means the account could be associated with an email address the registrant does not own. It means spam accounts can be created trivially with no verification barrier. For a platform that sends bulk email, this creates a direct path to abuse.
Email verification is now enforced as a gate on login. Registration creates the account and immediately sends a verification email. The login endpoint checks the emailVerified flag on the user record. An unverified account receives a 401 with a message directing the user to check their inbox and offering the option to resend the link.
The verification token is a cryptographically random 32-byte hex string. It expires in 24 hours. On use, it is marked as used rather than deleted, which preserves the record of when verification occurred. Creating a new verification token invalidates all previous unused tokens for the same user, ensuring only one active link exists at any time.
OAuth accounts bypass email verification entirely. When a user authenticates via Google or Microsoft, the provider has already verified ownership of the email address as part of the OAuth flow. Adding a verification barrier on top of that would be friction with no security benefit.
Invitation-created accounts also bypass verification. The invitation was delivered to the email address being registered with. Receipt of the invitation email is sufficient proof of address ownership. A second verification step would be redundant and confusing.
The resend endpoint always returns 200 regardless of whether an unverified account exists for the provided email. This is a standard anti-enumeration measure.

Input Validation and Sanitization

Every value that enters the system from a client request is treated as untrusted. This is the baseline, not the exception.
Validation is applied at the controller layer before any service method is called. Required fields are checked for presence and type. Email addresses are validated against a format regex. Enum values are validated against the known set before being passed to Prisma. Numeric values are checked for reasonable ranges. Dates are validated as parseable ISO 8601 strings before being passed to the database.
Sanitization is applied at the service layer before data is written to the database. Plain text fields like names, descriptions, and notes are passed through a sanitizer that strips all HTML tags and attributes. This prevents stored XSS payloads from being rendered in the frontend even if validation was somehow bypassed upstream.
Email template bodies are a special case. They contain formatting HTML by design because the templates are rendered in email clients. The sanitizer applied to template bodies uses an allowlist approach that preserves safe formatting elements while stripping scripts, event handlers, and any element capable of executing code in a browser context.
The sanitization library used is isomorphic-dompurify, which wraps DOMPurify in a way that works in Node.js without requiring a browser environment. The library operates on a conservative allowlist rather than a denylist, which means unknown elements are stripped by default rather than passed through.
Parameterized queries via Prisma prevent SQL injection at the database layer. No string interpolation is used in any query construction. Prisma's query builder handles parameterization automatically and the TypeScript type system enforces that raw query values are not inadvertently passed as query structure.

Rate Limiting Strategy

The global rate limiter applies to all routes at five hundred requests per fifteen-minute window per IP address. This is a baseline protection against trivial abuse but it is not sufficient for authentication endpoints.
Authentication endpoints have their own stricter limits. Login, register, forgot-password, reset-password, and complete-2FA are limited to ten attempts per fifteen minutes per IP. The skipSuccessfulRequests option means the counter only increments on failed attempts, which is the relevant signal for brute force and credential stuffing. Legitimate users authenticating successfully do not burn through their limit.
The resend-verification endpoint has a separate limit of five requests per hour. Without this, the endpoint could be used to spam any email address with repeated verification requests.
The rate limiters are defined in a dedicated shared file rather than inline in the server entry point. This was a structural necessity. If the auth route file imported rate limiters from the server file, and the server file imported the route file, a circular dependency would result. Extracting the rate limiters to their own module breaks the cycle cleanly.

CORS Configuration

The frontend is served from engage.monesize.com. The API is served from api.engage.monesize.com. These are different origins. Without explicit CORS configuration, every browser request from the frontend to the API would be blocked by the browser's same-origin policy.
The CORS configuration whitelists https://engage.monesize.com as the only allowed origin in production. Credentials are enabled because the authentication mechanism relies on HTTP-only cookies, which require credentials: true in the CORS configuration to be included in cross-origin requests.
In development, the configuration also allows localhost origins on ports 3000, 3001, and 5173 to support local development with both Next.js and Vite tooling.
Requests with no origin header are allowed in development only. No-origin requests come from server-to-server calls and command-line tools. Allowing them in production would bypass the origin restriction for server-side callers.
The CORS preflight response is cached for twenty-four hours. This reduces the number of preflight requests the browser makes for repeat cross-origin requests, which is a meaningful performance improvement for API-heavy frontends that make many different requests during a session.

Security Headers

Helmet applies a standard set of HTTP security headers by default. The Content Security Policy requires explicit configuration because the defaults are either too restrictive or too permissive for this specific application.
The CSP restricts script sources to the same origin. This is the most effective browser-side defense against cross-site scripting because it prevents inline scripts and externally loaded scripts from executing even if an attacker manages to inject HTML.
Image sources include both the API domain and the frontend domain. This is necessary for the email tracking pixel to function correctly in web-based email clients that render emails inline and apply the page's CSP to embedded images. Without this allowance, those clients would silently block the pixel.
The crossOriginEmbedderPolicy header is disabled. When enabled, this header prevents cross-origin resources from being loaded unless they opt in via specific headers. Email clients do not add those opt-in headers to their resource loading, so disabling the policy is necessary for tracking functionality to work correctly.
Frame embedding is set to DENY. There is no legitimate reason for the API to be embedded in an iframe and this setting prevents clickjacking attacks against any endpoint that renders HTML.

Graceful Shutdown

When the process receives a SIGTERM signal, which is what Docker, systemd, PM2, and Kubernetes all send when stopping a service, the application must finish handling in-flight requests before terminating.
The shutdown handler calls server.close() first. This stops the server from accepting new connections while allowing existing connections and in-flight requests to complete naturally. After the HTTP server reports that it has closed, the Prisma connection pool is explicitly disconnected. A ten-second timeout forces a process exit if the graceful shutdown stalls for any reason, which prevents zombie processes from persisting in production.
Without graceful shutdown, in-flight requests are dropped mid-execution when the process exits. A campaign worker that is mid-batch when the process dies leaves the campaign in a RUNNING state with partial sends recorded. The campaign resume feature handles recovery from exactly this scenario, but avoiding it in normal deployment and restart operations is better than relying on recovery.

The Audit Log as Infrastructure

Audit logging is a cross-cutting concern. The decision to treat it as shared infrastructure rather than a per-module concern was made deliberately, though not from the beginning.
The AuditLogService was initially placed inside the auth module because auth was the first module built and it needed audit logging. Every subsequent module imported the service from that location. This is a structural violation. A shared concern living in a feature module creates an implicit coupling between unrelated modules and makes the dependency graph misleading.
The service was moved to src/lib/audit-log.ts. The original file was replaced with a re-export to maintain backward compatibility during the transition without requiring simultaneous changes across thirteen controller files.
Every significant action that an organization member takes is logged with the actor, the action type, the target resource, the IP address, the user agent, and optional metadata. The log is append-only. Records are never updated or deleted through the application layer.
The service wraps every write in a try-catch that logs failures to the console without rethrowing. Audit log failures must never interrupt the primary operation they are recording. If the write fails, the user's action still succeeds. The failure is visible to operators in the application logs but does not surface to the user.

The Personalization Engine

Campaign emails support personalization variables in the {{variable_name}} format. These variables are resolved against the recipient contact's CRM data at send time, before the email is handed to the provider.
The engine is a regex replacement pass over the template subject and body. Every {{word}} match is looked up in a map built from the contact's fields. Known variables are replaced with their values. Unknown variables and variables whose values are null are replaced with an empty string.
The empty string fallback is intentional. A failed variable resolution produces a slightly awkward sentence rather than a broken email with a literal {{company_name}} placeholder visible to the recipient. Users are expected to preview templates before sending and notice missing variables, but the fallback ensures the send is never aborted because of a null field value.
The {{unsubscribe_url}} variable is special. It is generated fresh for each recipient using an HMAC token that encodes the contact's ID. This variable is available in every template regardless of whether the template author included it, and it is injected automatically into the tracked HTML body. Every campaign email contains a working unsubscribe link by construction.
Variables are extracted from template bodies at save time using the same regex and stored on the EmailTemplate record. This allows the frontend to display which variables a template uses without re-parsing the body on each render.

What Gets Validated Where

The controller layer handles validation of request inputs. It checks that required fields are present, that values match their expected types, and that enum values fall within the accepted set. This is the first line of defense against malformed requests.
The service layer handles business logic validation. It verifies that referenced resources exist and belong to the correct tenant, that the requesting member has the role required for the operation, and that the operation makes sense in the current state of the data. A request that passes controller-layer validation can still fail at the service layer.
Database constraints provide the final safety net. Unique constraints, not-null constraints, and foreign key constraints prevent invalid state from being written even if application-layer validation fails. These constraints should rarely fire in a correctly implemented application, but they are not optional. They are the guarantee that the data layer maintains its own integrity independently of the application.
This layering produces distinct and meaningful error types. A controller-layer failure returns a 400 with a message describing the specific field problem. A service-layer failure returns a 400, 403, or 404 depending on the nature of the problem. An unexpected database constraint violation reaches the central error handler and returns a 500, because reaching that state means the application layers above it were not working correctly.

Slug as Organizational Identity

Organization slugs are permanent, URL-safe identifiers generated from the organization name at creation time. They appear in organization profiles and configuration interfaces. They were previously required at login, which was the wrong use of them.
The slug is still meaningful as an identifier that humans can read and recognize. It appears in the API paths for organization-related configuration endpoints and in the workspace picker UI for disambiguating organizations with similar names. But a user should never need to recall it from memory as part of the authentication flow they perform every day.
The generation process handles conflicts by appending a short random suffix. A curated list of reserved slugs prevents organizations from registering names that collide with system paths, product names, or common infrastructure subdomains. Minimum and maximum length constraints are enforced. Slugs that generate to fewer than two characters or more than fifty characters are rejected with a clear error message rather than silently producing unusable identifiers.

What Was Built Wrong First

Honesty about mistakes is part of any honest engineering account.
The email configuration initially had SMTP as the only provider and used a method called saveSmtpConfig. This worked until the decision to support multiple providers made the naming wrong and the credential handling inconsistent. The fix was to introduce a unified saveConfig method that accepts a providerType field and dispatches to the correct validation and credential handling. The SMTP-specific endpoint was retained as a convenience wrapper that injects the provider type automatically.
The campaign send was synchronous from the start. This was always known to be insufficient for production scale but was built that way first to validate the end-to-end flow. The migration to BullMQ was planned from the beginning and was executed as part of the production hardening phase.
The audit log service lived inside the auth module for the entire early development phase. Every subsequent module imported it from that location. The structural problem was identified later and corrected by moving the service to the shared library.
Organization creation originally required the user to re-authenticate with their email and password even if they were already logged in. The reasoning was that creating an organization is a significant action. The correct reasoning is that the user already proved their identity by logging in and the active session is sufficient authorization for any action that role permits. The re-authentication requirement was removed.
The original login flow required users to provide their organization slug alongside their email and password. This was technically necessary to scope the session to a specific tenant, but it placed an unreasonable memory burden on users who might belong to multiple organizations over time. The login flow was redesigned so that users provide only email and password. The system resolves their workspaces automatically and presents a picker when multiple memberships exist.

Well Well…

Building Monesize Engage as a production-ready backend required making a consistent set of choices and holding to them across the entire surface area of the product. Multi-tenant isolation is enforced at the query level on every operation. Sessions are invalidated instantly through token versioning rather than blocklists. Provider credentials are encrypted before they reach the database. Campaign sending is asynchronous, batched, and recoverable. GDPR erasure is a distinct operation from soft delete with different semantics and different access controls. Email verification is a security gate, not a courtesy. Input is sanitized before storage.
The constraints that shaped these decisions were the constraints of a free product that must earn trust before it earns revenue. There was no room for deferred correctness on security or data integrity. The product either handles things correctly from the start or it handles them wrong indefinitely.
The stack is not exotic. Node.js, TypeScript, Express, Prisma, and PostgreSQL are well-understood, well-documented, and well-supported. The interesting work was not in the technology choices but in applying those technologies carefully and consistently to the specific requirements of a multi-tenant B2B product with real compliance obligations and a user base that should never have to think about the infrastructure underneath them.
The backend is the foundation for a product that organizations will eventually run their sales operations on. That is the standard it was built to. Always the standard for us at Monesize. 
Bye bye for now.

Comments

Popular Posts

Exploiting MS17-010 EternalBlue: SMB Flaw to SYSTEM Access

RecruitX: From Reconnaissance to Remote Code Execution

God Never Wrote a Book: A Nigerian Agnostic's Case