Saltar al contenido principal

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:

  1. A handler (backend) — validates and processes one row
  2. A schema entry (backend) — column list for the downloadable template
  3. A module registration (backend) — wires the handler into NestJS DI + registry
  4. A schema entry (frontend) — column list + required fields for the wizard UI
  5. 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 inside handle() 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 createdBy before 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 ImportTypeRegistry is a plain Map<string, ImportTypeHandler>. The string key you pass to registry.register() is the importType value clients send in POST /data-import/upload and 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 string
  • validate(): valid row → returns null
  • handle(): new entity → creates and returns entityId
  • handle(): existing entity (matching upsert key) → updates and returns same entityId
  • handle(): missing createdBy on 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

ConceptConventionExample
Import type keysnake_caseopening_inventory, my_entity
Handler classPascalCase + ImportHandlerMyEntityImportHandler
Handler filekebab-case + -import.handler.tsmy-entity-import.handler.ts
Test filesame as handler + .spec.tsmy-entity-import.handler.spec.ts
Route pathkebab-case/imports/my-entity
Template download<importType>-import-template.xlsxauto-generated by TemplateGeneratorService

End-to-end verification

  1. GET /data-import/templates/my_entity — downloads xlsx with correct headers
  2. Upload a CSV → POST /data-import/upload with importType=my_entity — returns { jobId, totalRows }
  3. GET /data-import/jobs/:jobId?businessId=... — poll until status=completed
  4. Verify records exist in the DB
  5. Re-upload same file → records updated, no duplicates (upsert)
  6. Upload file with a bad row → GET /data-import/jobs/:jobId/errors?format=csv returns the error row
  7. Frontend: Import Center shows "My Entities" card; wizard opens at the correct route and lists all target fields in the mapping step