Adding a New Import Type
This guide describes every file you must touch to add a new bulk import type (e.g. supplier, location, unit_of_measure) to the Data Import module.
Document version: 1.0 · Last updated: 2026-02
Architecture recap
The import pipeline works as follows:
HTTP upload → FileParserService (xlsx/csv → rows[])
→ DataImportService.createJob() → importJob DB row (status=pending)
→ BullMQ queue
→ ImportProcessingProcessor (worker)
→ ImportTypeRegistry.get(importType) ← looks up your handler
→ handler.validate(row)
→ handler.handle(row) ← upsert/create
→ importRow DB rows (success/error)
→ importJob status=completed
Each import type needs:
- A handler (backend) — validates and processes one row
- A schema entry (backend) — column list for the downloadable template
- A module registration (backend) — wires the handler into NestJS DI + registry
- A schema entry (frontend) — column list + required fields for the wizard UI
- A dashboard card (frontend) — icon + route on the Import Center page
Backend checklist
1. Create the handler
Location: apps/backend/src/data-import/application/handlers/<type>-import.handler.ts
Implement the ImportTypeHandler interface:
import type {
ImportHandlerContext,
ImportTypeHandler,
} from "@/data-import/application/import-type-handler.interface";
import { Injectable } from "@nestjs/common";
@Injectable()
export class MyEntityImportHandler implements ImportTypeHandler {
constructor(private readonly myEntityService: MyEntityService) {}
validate(row: Record<string, unknown>, _context: ImportHandlerContext): string | null {
if (!row.name || String(row.name).trim() === "") return "Name is required";
// Return null if valid
return null;
}
async handle(
row: Record<string, unknown>,
context: ImportHandlerContext,
): Promise<{ success: true; entityId: string } | { success: false; error: string }> {
const err = this.validate(row, context);
if (err) return { success: false, error: err };
if (!context.createdBy) {
return { success: false, error: "Import requires authenticated user" };
}
// ... resolve references, upsert logic ...
return { success: true, entityId: created.id };
}
}
Key rules:
validate()is synchronous and must be called first insidehandle()too (the processor calls it, but be defensive).- Return
{ success: false, error: "..." }for business errors instead of throwing — throwing is reserved for unexpected infrastructure failures (DB down, etc.). - Always gate
createdBybefore any insert. Updates can proceed without it if the entity already exists. - Use upsert semantics: look up an existing record by a natural key (code, email, sku), update if found, create if not. This makes re-imports safe.
Country resolution
If your entity has a countryId field, copy the resolveCountryId helper from customer-import.handler.ts or supplier-import.handler.ts. It resolves ISO codes, full names, and common aliases.
Idempotency for ledger-writing types
If your handler writes to financial or inventory ledgers (like opening_inventory), use an idempotencyKey column on the ledger table to prevent duplicate entries on BullMQ retries. The processor already skips rows whose rowNumber was already saved, but the key is a second layer of defence for the ledger itself.
2. Add the template schema
File: apps/backend/src/data-import/application/template-schema.service.ts
Add an entry to IMPORT_TYPE_SCHEMAS:
my_entity: {
headers: ["name", "code", "optionalField"],
},
The headers array determines the columns in the downloadable Excel template and the display order. List required fields first, optional last.
Some optional columns have format rules (e.g. product taxes: empty = default, none = no taxes, IVA or IVA:12|ISR = one or more taxes with optional rate override). Document these in the user guide.
3. Register the handler in the module
File: apps/backend/src/data-import/data-import.module.ts
Three places to edit:
// 1. Add import statement
import { MyEntityImportHandler } from "@/data-import/application/handlers/my-entity-import.handler";
import { MyEntityModule } from "@/my-entity/my-entity.module"; // if needed
// 2. Add to @Module imports[] (only if the handler needs a service from another module)
imports: [
// ...existing modules...
MyEntityModule,
],
// 3. Add to providers[]
providers: [
// ...existing handlers...
MyEntityImportHandler,
],
// 4. Add to useFactory signature, body, and inject array
useFactory: (
// ...existing params...
myEntityHandler: MyEntityImportHandler,
) => {
const registry = new ImportTypeRegistry();
// ...existing registrations...
registry.register("my_entity", myEntityHandler);
return registry;
},
inject: [
// ...existing tokens...
MyEntityImportHandler,
],
The
ImportTypeRegistryis a plainMap<string, ImportTypeHandler>. The string key you pass toregistry.register()is theimportTypevalue clients send inPOST /data-import/uploadand that you'll use in the frontend schema.
4. Write a unit test
Location: apps/backend/src/data-import/application/handlers/__tests__/<type>-import.handler.spec.ts
Use any existing test as a reference (e.g. brand-import.handler.spec.ts). Cover at minimum:
validate(): missing required field → returns error stringvalidate(): valid row → returns nullhandle(): new entity → creates and returns entityIdhandle(): existing entity (matching upsert key) → updates and returns same entityIdhandle(): missingcreatedByon insert → returns error
5. Lint
pnpm run lint
Frontend checklist
1. Add to import-type-schemas.ts
File: apps/frontend-pwa/src/components/forms/data-import/import-type-schemas.ts
// In IMPORT_TYPE_LABELS:
my_entity: "My Entities",
// In IMPORT_TYPE_SCHEMAS:
my_entity: {
headers: ["name", "code", "optionalField"],
required: ["name"], // fields the user MUST map before running
},
The headers list drives the Target field dropdown in the column-mapping step of the wizard. It must match the backend handler's expected field names (after mapping is applied). The required array blocks the "Run import" button until those fields are mapped.
2. Add a card to the dashboard
File: apps/frontend-pwa/src/components/forms/data-import/ImportCenterDashboardPage.tsx
// 1. Add the icon import from lucide-react
import { ..., SomeIcon } from "lucide-react";
// 2. Add to IMPORT_TYPES array
{ key: "my_entity", path: "/imports/my-entity", icon: SomeIcon },
The route (/imports/my-entity) is handled generically by ImportWizardPage which reads the importType from the URL — no new route component needed.
Naming conventions
| Concept | Convention | Example |
|---|---|---|
| Import type key | snake_case | opening_inventory, my_entity |
| Handler class | PascalCase + ImportHandler | MyEntityImportHandler |
| Handler file | kebab-case + -import.handler.ts | my-entity-import.handler.ts |
| Test file | same as handler + .spec.ts | my-entity-import.handler.spec.ts |
| Route path | kebab-case | /imports/my-entity |
| Template download | <importType>-import-template.xlsx | auto-generated by TemplateGeneratorService |
End-to-end verification
GET /data-import/templates/my_entity— downloads xlsx with correct headers- Upload a CSV →
POST /data-import/uploadwithimportType=my_entity— returns{ jobId, totalRows } GET /data-import/jobs/:jobId?businessId=...— poll untilstatus=completed- Verify records exist in the DB
- Re-upload same file → records updated, no duplicates (upsert)
- Upload file with a bad row →
GET /data-import/jobs/:jobId/errors?format=csvreturns the error row - Frontend: Import Center shows "My Entities" card; wizard opens at the correct route and lists all target fields in the mapping step