From Product Experience to Product Operations: Building PromoBet’s Administrative Architecture

How PromoBet evolved from a product-focused application into a system with a dedicated administrative boundary, global catalog ingestion, validation, and operational infrastructure.

PromoBetSoftware ArchitectureBackend DevelopmentSystem DesignProduct IngestionData ValidationFirebaseFirestoreFull-Stack Development

There is a particular kind of complexity that appears in a project as it grows.

It is not necessarily caused by a single difficult feature.

Sometimes it comes from the moment when a system that was originally designed to do something also needs to start managing the things that make that something possible.

PromoBet reached that point recently.

For a long time, most of the architecture revolved around the product experience itself: tenants, users, products, spins, rewards, engagement, analytics, ingestion pipelines, validation, and the various pieces necessary to make those features work together.

But eventually we needed to answer a different question:

How do we actually operate the system?

How do we introduce products into the global catalog?

How do we validate them before they become part of the experience?

How do we distinguish system-level operations from tenant-level operations?

Where should administrative functionality live?

And perhaps most importantly:

How do we introduce all of this without turning the existing application into an increasingly tangled collection of unrelated responsibilities?

The solution became the introduction of a separate administrative application, promobet-admin, backed by a dedicated system-administration layer in the API.

What initially sounds like “adding an admin app” turned out to be a much more architectural change.

It forced us to clarify boundaries that had previously existed mostly implicitly.

And, in doing so, it finally gave the ingestion architecture we had been building over the previous months a real operational entry point.


The architecture before the administrative layer

PromoBet had already been moving toward a fairly deliberate separation of concerns.

The product system had evolved beyond simply storing a list of products and displaying them.

We had introduced an ingestion pipeline capable of accepting different external representations and converting them into a canonical internal product model.

Conceptually, the flow looked like:

text
External Product Data

Adapter

Normalization

Enrichment

Validation

Canonical Product

Persistence

This distinction became increasingly important as we started dealing with different product sources.

A CSV file does not necessarily represent a product in the same way as a JSON payload.

One provider might call something product_name.

Another might call it title.

A price might be represented as a number:

json
{
  "price": 100
}

or as a nested object:

json
{
  "price": {
    "value": 100
  }
}

Affiliate information can have different representations.

URLs can appear under different fields.

Commission data can arrive in different formats.

Instead of allowing those differences to propagate throughout the application, the ingestion architecture became responsible for translating them into one canonical representation.

That was an important decision.

The rest of the application should not need to know where a product came from.

It should simply receive a valid PromoBet product.


The ingestion pipeline became more than a parser

One of the lessons from this work was that ingestion is not really about parsing.

Parsing is only the beginning.

The more useful abstraction is:

external data → trusted application data

That requires several stages.

Adapters

Adapters understand external representations.

They answer questions such as:

Where is the product name in this particular format?

Which field contains the affiliate URL?

Is the price primitive or nested?

Normalization

Normalization removes representation differences.

The goal is to stop carrying provider-specific naming conventions further into the application.

Enrichment

Some information can be derived rather than directly supplied.

For example, category or affiliate provider information can sometimes be inferred from existing product data.

Validation

Validation establishes whether the resulting object is actually acceptable to PromoBet.

This includes things such as:

  • required fields
  • valid URLs
  • valid product structure
  • supported tiers
  • normalized pricing
  • commission information
  • other domain constraints

Transformation

Finally, a validated product can be transformed into the representation appropriate for its destination.

This distinction became especially important once we introduced the global catalog.


The global catalog was a different responsibility

PromoBet already had tenant-specific product catalogs.

Those products lived under tenant scope:

text
tenants/{tenantId}/products/{productId}

That model makes sense when the product belongs to a particular tenant.

But we needed another concept.

A product catalog that could be managed at the system level and used by the global/default experience.

That meant introducing:

text
globalCatalog/{productId}

At first glance, this can look like nothing more than another Firestore collection.

Architecturally, it is more significant than that.

We now had two different scopes of product ownership:

text
Tenant Product

Tenant scope

tenants/{tenantId}/products

and:

text
Global Product

System scope

globalCatalog

The products themselves could still share the same canonical model.

The ownership and operational context were different.

That distinction mattered.


Why we did not create a second product pipeline

A tempting approach would have been to create something like:

text
Tenant CSV Import
Tenant JSON Import

Global CSV Import
Global JSON Import

with each implementation gradually developing its own rules.

That would work initially.

It would also be exactly the kind of duplication that becomes expensive later.

Instead, the decision was to keep ingestion independent from persistence scope.

The pipeline remains responsible for turning external product data into validated PromoBet products.

The destination decides what happens afterward.

Conceptually:

text
                   ┌── Tenant Catalog

Validated Product ─┤

                   └── Global Catalog

This is one of the more important architectural decisions in this iteration.

Product ingestion and product ownership are related concepts, but they are not the same responsibility.


Introducing promobet-admin

Once the global catalog became operational data, another question immediately appeared:

Who manages it?

We could have added administrative controls directly into the main PromoBet application.

But that would blur another boundary.

The application used by customers and tenants is fundamentally different from an application used to operate the system.

The former is concerned with the product experience.

The latter is concerned with the state of the platform itself.

So we introduced a separate application:

text
promobet-admin

This creates a much clearer relationship:

text
PromoBet

Product experience

promobet-admin

System operations

The distinction is not simply visual.

It exists at the API and authorization levels as well.


Authentication is not authorization

One of the first things that became important was distinguishing authentication from authorization.

PromoBet already had Firebase authentication.

That answers:

Who is this user?

But the administrative API needs to answer a different question:

Is this authenticated user allowed to perform this system-level operation?

Those became two explicit middleware responsibilities.

text
verifyFirebaseToken

Who are you?

requireSystemAdmin

Are you allowed to perform this operation?

The system-admin check uses the user's Firestore record and requires:

text
mode === "system_admin"

This is important because the existence of promobet-admin should never be treated as the security boundary.

The frontend can provide an administrative interface.

It cannot grant administrative privileges.

The API remains authoritative.


/admin/me became the first administrative boundary

The first endpoint introduced for this purpose was intentionally simple:

text
GET /api/admin/me

Its job is not to perform an administrative operation.

It establishes the administrative identity.

The flow becomes:

text
Firebase Login

Firebase ID Token

GET /admin/me

verifyFirebaseToken

requireSystemAdmin

Authorized administrator

This gave promobet-admin a backend-authoritative way to establish whether the current authenticated user actually belongs to the system administration layer.

That may seem like a small endpoint.

Architecturally, it is the beginning of the administrative API surface.


CSV was the first real operational workflow

The next step was making the architecture actually do something useful.

We needed to take real products and put them into the global catalog.

The first implementation was CSV ingestion:

text
POST /api/admin/globalCatalog/import/csv

The endpoint combines several pieces that had previously existed somewhat independently.

text
Admin authentication

System authorization

Multipart upload

CSV adapter

Normalization

Enrichment

Validation

Global transformation

Firestore

This was the moment when the ingestion architecture stopped being primarily infrastructure and became part of the actual product operation.


Dry runs became particularly valuable

Importing external data directly into a production catalog is inherently risky.

Even if validation exists, there is a meaningful difference between:

“The system says these products are valid.”

and:

“Let me see exactly what the system is going to import before I commit it.”

So the administrative import API supports a dry-run mode:

text
?dryRun=true

The API processes the file normally but stops before persistence.

The result contains information such as:

text
total
validCount
errorCount
warningsCount
products
items
errors

This gives the administrative application enough information to build a preview.

The workflow becomes:

text
Upload

Parse

Validate

Preview

Human confirmation

Import

That is a much safer operational model than treating an upload as an immediate database mutation.


JSON exposed another useful architectural property

Once CSV was working, JSON was the natural next step.

But there was no reason to build another UI or another conceptual workflow.

The difference is only at the input boundary.

CSV arrives as a file.

JSON arrives as structured data.

After that, both should converge.

text
              ┌── CSV

External ─────┤

              └── JSON

               Adapter

              Normalization

                Enrichment

                Validation

             Global Catalog

The JSON implementation therefore follows the same verification contract as CSV.

This was also a useful test of whether the abstraction was actually working.

If adding another input format requires rebuilding the business rules, the abstraction is probably in the wrong place.

In this case, the new JSON path could reuse the same product-processing architecture.


A small problem revealed an important distinction

During JSON ingestion, we encountered a realistic external-data problem.

A price could arrive as:

json
{
  "price": {
    "value": 100
  }
}

rather than:

json
{
  "price": 100
}

The first implementation assumed too much about the input shape.

That resulted in validation failures before the product could reach the catalog.

The solution was not to make the validation layer understand every possible external representation.

Instead, the JSON adapter became responsible for flattening and normalizing those values before the canonical product entered the common pipeline.

That reinforces an important rule:

Provider-specific weirdness belongs at the boundary.

The deeper layers should receive predictable data.


Transformation and persistence were also separated

Another subtle change was separating:

text
toGlobalCatalogProduct()

from:

text
importGlobalProducts()

The first transforms a canonical product into the representation required by the global catalog.

The second persists it.

That means we can reason about them independently:

text
Canonical Product

Transformation

Global Catalog Product

Persistence

This prevents the database helper from becoming a second business-logic layer.

It also leaves room for future destinations without forcing persistence functions to understand how products should be constructed.


The catalog resolver was the final piece

Getting products into Firestore is only half of the problem.

The application also needs to consume them.

The existing catalog resolver already provided a useful abstraction:

text
getCatalogForTenant()

That abstraction allowed us to introduce the global catalog without forcing the rest of the spin system to understand Firestore collection structure.

The resolver now distinguishes between:

text
tenant_pool
global_pool
legacy_fallback

The logic is roughly:

text
Is there a tenant?

      ├── Yes
      │    ↓
      │  Tenant products

      └── No

      Global products

           ├── Available
           │     ↓
           │   globalCatalog

           └── Empty

             Legacy data

This was an important architectural choice because the spin experience itself does not need to know where the products came from.

It asks for a catalog.

The resolver decides what that catalog means in the current context.


Keeping the legacy fallback was intentional

There was also no reason to delete the existing static catalog immediately.

The previous lomadeeProducts data remains available as a legacy fallback.

That gives us a gradual transition:

text
Static catalog

Legacy fallback

Firestore globalCatalog

Primary global source

This is useful because migrations do not always need to happen in one dramatic switch.

The new path can prove itself while the old path remains available as a safety net.

Once the Firestore-backed catalog is sufficiently mature, the legacy fallback can eventually be removed.


The moment the architecture became real

The most satisfying part of this implementation was not actually the administrative UI.

It was the first successful end-to-end flow.

We uploaded real product data.

The API processed it.

The ingestion pipeline normalized it.

The verification layer accepted it.

The administrative interface showed the result.

The products were committed to Firestore.

And then the existing global spin experience consumed those products.

The first CSV dry run returned:

text
20 total
20 valid
0 errors
0 warnings

Those products were then committed to globalCatalog.

The global spin subsequently consumed the imported records.

That completed the loop:

text
Real Product Data

promobet-admin

PromoBet API

Authentication

System Authorization

Ingestion

Validation

Firestore

Catalog Resolver

Existing Product Experience

At that point, the architecture was no longer theoretical.

The system we had been building could finally ingest real products and put them directly into the experience.


Complexity increased — but so did clarity

There is an interesting contradiction in this change.

The system is objectively more complex now.

We have:

  • another application
  • another API surface
  • system-admin authorization
  • another Firestore collection
  • administrative workflows

On paper, there are more moving pieces.

But the architecture is also clearer.

Before, system-level operations had no obvious home.

Now they do.

Product ingestion had to coexist with application behavior.

Now the ingestion pipeline has a defined boundary.

Global products could have become mixed into tenant concepts.

Now their scopes are explicit.

Authentication and authorization could have been conflated.

Now they are separate responsibilities.

Administrative behavior could have leaked into the customer-facing application.

Now there is a dedicated entry point.

This is an important kind of architectural progress:

sometimes adding structure reduces conceptual complexity even while increasing technical complexity.


promobet-admin is more than a catalog importer

The global catalog is only the first capability.

The administrative application now gives us a natural place for functionality that does not belong in the customer-facing experience.

As the platform evolves, the same boundary can support things such as:

Analytics

System-level views over:

  • spins
  • rewards
  • clicks
  • engagement
  • tenant activity
  • product performance

Users

Operational visibility into:

  • users
  • tenant users
  • activity
  • account state
  • operational issues

Subscriptions

Administrative views for:

  • subscription state
  • tenant plans
  • usage
  • lifecycle information

Support and interaction

Eventually, system administrators may need to interact with:

  • user reports
  • contact requests
  • support requests
  • operational problems
  • other user-generated interactions

None of these belong naturally inside the product experience itself.

They belong to the operational layer of the platform.

That layer now has a home.


The architecture is starting to reflect the organization of the product

One of the things I find most interesting about this stage of PromoBet is that the architecture is beginning to mirror the conceptual structure of the product itself.

There is a product experience.

There are tenants.

There are users.

There is global system data.

And now there is a system administration layer responsible for operating all of them.

The architecture is no longer just a collection of technical decisions made to make individual features work.

The boundaries are beginning to represent actual responsibilities within the platform.

That makes future development easier to reason about.

When a new requirement appears, the first question becomes less:

Where can we put this?

and more:

Which part of the system is actually responsible for this?

That is a much healthier question.


What we deliberately did not build yet

It would have been easy to continue expanding the administrative layer once the foundation existed.

We deliberately stopped.

There is no elaborate catalog management suite yet.

There is no import history system.

There is no catalog versioning.

There is no global catalog caching strategy.

There is no complete administrative analytics dashboard.

There is no subscription management interface.

There is no support-ticket system.

Those are all potentially useful.

But they are separate problems.

The goal of this iteration was narrower:

create the boundary and prove the complete flow.

Now that the boundary exists, those capabilities can be added incrementally.


From infrastructure to operation

Looking back at the evolution, the interesting part is how several seemingly separate pieces finally connected.

The ingestion architecture gave us a way to transform external product data into trusted application data.

The validation layer gave us confidence in that data.

The global catalog gave us a persistent system-level destination.

The catalog resolver gave the existing experience a way to consume it.

Firebase authentication gave us identity.

System-admin authorization gave us a security boundary.

And promobet-admin gave all of those capabilities a user-facing operational entry point.

Individually, none of these pieces is particularly revolutionary.

Together, they change what the system can actually do.

We can now take something that exists outside PromoBet a real product catalog and move it through a controlled process:

text
External Data

Interpret

Normalize

Enrich

Validate

Review

Persist

Resolve

Experience

That is a meaningful transition.


The next phase

This is probably the most important takeaway from this iteration.

We did not simply add an admin page.

We established a new axis of the system.

The customer-facing application remains focused on the product experience.

The administrative application becomes the operational interface for the platform.

The API enforces the boundary between them.

The ingestion architecture provides a controlled path for external data.

And Firestore provides the persistent system-level catalog that connects administration to runtime behavior.

From here, additional administrative capabilities do not need to be invented from scratch.

They can grow naturally from the same foundation.

That is ultimately what makes the added complexity worthwhile.

We have more code than we had before.

But we also have clearer boundaries, a safer operational model, a real administrative entry point

And that feels like a much more meaningful milestone than simply saying:

“We added an admin panel.”