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.
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:
External Product Data
↓
Adapter
↓
Normalization
↓
Enrichment
↓
Validation
↓
Canonical Product
↓
PersistenceThis 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:
{
"price": 100
}or as a nested object:
{
"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:
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:
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:
Tenant Product
↓
Tenant scope
↓
tenants/{tenantId}/productsand:
Global Product
↓
System scope
↓
globalCatalogThe 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:
Tenant CSV Import
Tenant JSON Import
Global CSV Import
Global JSON Importwith 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:
┌── Tenant Catalog
│
Validated Product ─┤
│
└── Global CatalogThis 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:
promobet-adminThis creates a much clearer relationship:
PromoBet
↓
Product experience
promobet-admin
↓
System operationsThe 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.
verifyFirebaseToken
↓
Who are you?
requireSystemAdmin
↓
Are you allowed to perform this operation?The system-admin check uses the user's Firestore record and requires:
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:
GET /api/admin/meIts job is not to perform an administrative operation.
It establishes the administrative identity.
The flow becomes:
Firebase Login
↓
Firebase ID Token
↓
GET /admin/me
↓
verifyFirebaseToken
↓
requireSystemAdmin
↓
Authorized administratorThis 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:
POST /api/admin/globalCatalog/import/csvThe endpoint combines several pieces that had previously existed somewhat independently.
Admin authentication
↓
System authorization
↓
Multipart upload
↓
CSV adapter
↓
Normalization
↓
Enrichment
↓
Validation
↓
Global transformation
↓
FirestoreThis 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:
?dryRun=trueThe API processes the file normally but stops before persistence.
The result contains information such as:
total
validCount
errorCount
warningsCount
products
items
errorsThis gives the administrative application enough information to build a preview.
The workflow becomes:
Upload
↓
Parse
↓
Validate
↓
Preview
↓
Human confirmation
↓
ImportThat 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.
┌── CSV
│
External ─────┤
│
└── JSON
↓
Adapter
↓
Normalization
↓
Enrichment
↓
Validation
↓
Global CatalogThe 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:
{
"price": {
"value": 100
}
}rather than:
{
"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:
toGlobalCatalogProduct()from:
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:
Canonical Product
↓
Transformation
↓
Global Catalog Product
↓
PersistenceThis 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:
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:
tenant_pool
global_pool
legacy_fallbackThe logic is roughly:
Is there a tenant?
│
├── Yes
│ ↓
│ Tenant products
│
└── No
↓
Global products
│
├── Available
│ ↓
│ globalCatalog
│
└── Empty
↓
Legacy dataThis 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:
Static catalog
↓
Legacy fallback
Firestore globalCatalog
↓
Primary global sourceThis 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:
20 total
20 valid
0 errors
0 warningsThose products were then committed to globalCatalog.
The global spin subsequently consumed the imported records.
That completed the loop:
Real Product Data
↓
promobet-admin
↓
PromoBet API
↓
Authentication
↓
System Authorization
↓
Ingestion
↓
Validation
↓
Firestore
↓
Catalog Resolver
↓
Existing Product ExperienceAt 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:
External Data
↓
Interpret
↓
Normalize
↓
Enrich
↓
Validate
↓
Review
↓
Persist
↓
Resolve
↓
ExperienceThat 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.”