Sheet 02 · engineering breakdown

← Portfolio  ·  Русская версия

B2B portal · Next.js App Router

2025

Ventprom

A ventilation equipment portal where a buyer gets from “I need 8,000 m³/h” to a bill of materials and a quote without a sales engineer in the loop: an engineering-faceted catalogue, section-by-section air handling unit selection, a parametric duct fitting configurator, and a customer workspace — all sitting on top of an item catalogue that lives in 1C.

Role
sole developer: architecture, data model, calculation layer, configurator, admin back office, 1C integration, front end
Stack
Next.js (App Router) · React 19 · TypeScript · PostgreSQL + Prisma · Meilisearch · NextAuth · Zustand · Three.js / React Three Fiber · Telegraf
Zones
storefront, customer workspace, admin configurator — three route groups in one application
Size
974 TypeScript files, 214,974 lines under src/
Database
125 Prisma models, 29 migrations
Status
production portal: needs Postgres, Meilisearch and 1C access — no public demo, but two self-contained pieces were split out

01 · The problem

Every enquiry had to pass through an engineer's head

A ventilation manufacturer sells two fundamentally different things. The first is stocked items — fans, grilles, fasteners — where an ordinary catalogue does the job. The second is product that does not exist until it is ordered: an air handling unit assembled from sections for a specific air flow, and duct fittings cut to the dimensions of a specific building.

The second half used to be done by people. The buyer sent an email or phoned, an engineer opened a legacy web configurator run by a subcontractor, picked a casing size, assembled the section train, manually added the controls — sensors, drives, control panel — and sent an answer back. One round trip took hours to a day, and every change of dimensions started it over.

The second source of pain was drift between the site and 1C. Prices, stock and the item catalogue itself are maintained in the accounting system, while the storefront lives its own life. As long as the link between them is a manual export, the storefront lies by construction, and a sales manager re-checks every line anyway.

The third was knowledge that was written down nowhere. Which sensors a water heater requires, when the control panel must be steel rather than plastic, why face velocity must not exceed 3.5 m/s once a humidifier is in the train — all of it lived in one engineer's head and in someone else's legacy script that could be neither read nor verified.

The portal had to turn those three things into code: the selection rules, the link to the accounting system, and the customer's path from air flow to document — with no intermediary.

02 · How it is built

Three zones in one application, separated by route groups

The storefront, the customer workspace and the back office are three different products with different layouts, different navigation and different permissions. In the App Router they live as three route groups in one tree: (site), (workspace), (admin). Each brings its own layout.tsx, but all three share one data layer, one Prisma schema and one calculation core. Access to the admin zone is cut off in middleware.ts by the role in the token, before a single component renders.

(site) · storefront

Catalogue, selection, ordering

200 files, 100,877 lines. A catalogue faceted by application, by capacity and by equipment category; a five-step selection wizard; cart, quick order, checkout. Dedicated sections for fans, heaters, silencers, dampers, condensing units and mixing assemblies — each with its own parameter card.

(workspace) · customer zone

What happens after the sale

Orders and their statuses, quotes, customer sites, installations, helpdesk, documents, purchasing analytics, reorder templates. This zone is what turns a one-off purchase into a history: the next order for the same site is assembled from the previous one rather than from scratch.

(admin) · configurator

44 equipment reference tables

Fans, filters and filter inserts, water and electric heaters, coolers, evaporators, three types of heat recovery unit, dampers, flexible connectors, silencers, humidifiers, mixing sections, valve actuators, variable frequency drives, sensors, control panels, casing sizes and product series — plus pricing, bulk operations, import comparison and referential integrity checks.

The catalogue is split into family and variant: ProductFamily holds the description, documentation and SEO metadata, ProductVariant holds one concrete size with air flow, pressure, power, sound level, dimensions, price and stock. Facets filter variants, not families: an engineer searches for “under 65 dB and above 3,000 m³/h”, not for a series name. Both tables carry ref1C and code1C — the join keys to the accounting system.

Facets are routes rather than client state: /catalog/application/[type], /catalog/capacity/[range], /catalog/equipment/[category], with numeric ranges arriving in searchParams and collapsing into a single Prisma where on the server. The reason is not technical: a filtered selection has to be forwardable to a colleague as a link, and the page has to open with a list already in it rather than with a spinner.

03 · The calculation layer

The physics is kept out of both the UI and the database

src/lib/engineering/ — 25 modules, 13,227 lines — knows nothing about React, routing or sessions. Numbers and a section train go in; numbers plus two arrays come out: warnings[] and errors[]. Next to it sits src/lib/calculators/, another 12 modules: duct aerodynamics, psychrometrics, climate data, code requirements, kitchen hoods, swimming pools, valve Kvs.

Three reasons to keep them separate. First, the same formulas are called from four places — server components in the storefront, API routes, the Telegram bot, and a standalone static calculator site with no database at all.

Second, pure functions can be checked without standing up infrastructure. Water heater selection is seven numbers in and fourteen out; it needs neither Postgres nor authentication.

Third, a calculation has to be calibratable against real hardware. An empirical pressure-drop formula gives an approximation, and a specific coil from the reference tables behaves differently. So every heat exchanger calculation accepts a set of correction factors stored alongside the database record. All default to 1.0 — the formula works uncalibrated.

Casing size selection: velocity limits accumulate over the section train src/lib/engineering/size-variant-selector.ts
// Limits recovered from the legacy configurator's logs and checked against its own results
const VELOCITY_LIMITS = {
  DEFAULT_MIN: 1.5,          // m/s — below this, coils work inefficiently
  DEFAULT_MAX: 8.0,          // m/s — ceiling with no extra sections
  WITH_RECUPERATOR: 9.0,
  WITH_HEATER: 5.0,
  WITH_COOLER: 4.0,
  WITH_HUMIDIFIER: 3.5,      // above this, droplets carry over
  OPTIMAL_MIN: 2.5, OPTIMAL_MAX: 3.5,
};

let maxVelocity = VELOCITY_LIMITS.DEFAULT_MAX;
if (hasRecuperator) maxVelocity = Math.min(maxVelocity, VELOCITY_LIMITS.WITH_RECUPERATOR);
if (hasHeater)      maxVelocity = Math.min(maxVelocity, VELOCITY_LIMITS.WITH_HEATER);
if (hasCooler)      maxVelocity = Math.min(maxVelocity, VELOCITY_LIMITS.WITH_COOLER);
if (hasHumidifier)  maxVelocity = Math.min(maxVelocity, VELOCITY_LIMITS.WITH_HUMIDIFIER);

for (const size of sizes) {
  const faceArea = (size.width / 1000) * (size.height / 1000);   // m²
  const maxVel = Math.max(
    airFlowSupply  / 3600 / faceArea,
    airFlowExhaust / 3600 / faceArea,
  );

  if (maxVel > maxVelocity) continue;   // casing too small
  if (maxVel < minVelocity) continue;   // casing too large

  variants.push({
    sizeId: size.id, width: size.width, height: size.height, faceArea,
    isOptimal: maxVel >= VELOCITY_LIMITS.OPTIMAL_MIN
            && maxVel <= VELOCITY_LIMITS.OPTIMAL_MAX,
    warnings,
  });
}

Trimmed: calculation logging and air property fill-in removed. The point of the fragment is a rule that only the engineer used to know — every added section lowers the velocity ceiling, so the casing is sized against the tightest constraint, not against the air flow. The “optimal” flag comes from the same place: landing inside 2.5–3.5 m/s. The customer is shown the whole shortlist rather than a single answer, with notes on why one option beats another.

Correction factors: the formula stays honest, but can be reconciled with the datasheet src/lib/engineering/section-calculators.ts
export interface HeatExchangerCorrectionFactors {
  power?: number;         // duty
  airPressure?: number;   // air-side pressure drop
  waterPressure?: number; // water-side pressure drop
  velocity?: number;
  surface?: number;       // surface margin
}

/** No factor supplied — the value passes through untouched */
function applyCorrection(value: number, factor?: number): number {
  return factor !== undefined && factor !== null ? value * factor : value;
}

// inside calculateWaterHeater(): physics first…
const massFlowAir  = (input.airflow / 3600) * airDensity(input.inletTemp);
const thermalPower = massFlowAir * 1.005 * (input.desiredOutletTemp - input.inletTemp);

// …then the checks a human used to make…
if (waterVelocity < 0.5)
  warnings.push(`Water velocity ${waterVelocity.toFixed(2)} m/s is below minimum — freezing risk`);
if (velocity > 5.0)
  warnings.push(`Face velocity ${velocity.toFixed(2)} m/s exceeds the limit for a heating coil`);

// …and only at the end, calibration against a specific model from the reference tables
const cf = input.correctionFactors;
if (cf) {
  correctedPower    = applyCorrection(thermalPower,      cf.power);
  airPressureDrop   = applyCorrection(airPressureDrop,   cf.airPressure);
  waterPressureDrop = applyCorrection(waterPressureDrop, cf.waterPressure);
  surfaceReserve    = applyCorrection(surfaceReserve,    cf.surface);
}

The ordering matters more than the formulas. Physics is computed first, warnings are raised against the raw quantities, and correction is applied last. That way a “freezing risk” warning cannot be silently tuned away by a coefficient, and the gap between calculation and datasheet stays visible: a factor that drifts far from 1.0 is a signal that the formula does not fit this product series — not an invitation to nudge it.

04 · Air handling unit configurator

A unit is an ordered section train, not a product card

An air handling unit is assembled from sections standing one after another inside a casing: damper, filter, heat recovery, heating coil, fan, silencer. It has two branches — supply and extract — and the order of sections has physical meaning: air leaving the heat recovery unit arrives at the coil in a different state. So the configurator is built as a pipeline where the outlet of one section becomes the inlet of the next.

  1. Casing size selection selectSizeVariants() walks the casing sizes of a series from the database, computes face velocity and keeps the ones inside the limits. It returns a shortlist with “optimal” flags and warnings rather than a single answer.
  2. Section-by-section calculation 12 calculators in SectionCalculators: filter, rotary and plate heat recovery, water and electric heating, water and DX cooling, fan, silencer, mixing section, damper, flexible connector. Each takes a SectionInput — air flow, cross-section, inlet temperature and humidity — and returns a SectionResult with the outlet air state. The psychrometrics are real: moisture content, enthalpy, dew point, and wet-bulb temperature via the Stull approximation.
  3. Topology validation 18 rules in build-validation.ts check order rather than numbers: at most one fan per branch, a filter must precede heat recovery and any coil, humidifiers cannot sit just anywhere, glycol recovery coils only come in pairs, a gas-fired heater requires an empty section behind it. Findings split into blocking errors, which stop the calculation, and warnings, which simply sit next to the result.
  4. Bill of materials assembly BOMService adds what the customer never thinks about: differential pressure switches, frost protection, valve actuators, variable frequency drives, the control panel. The set is derived from the section train — each sensor in the reference tables is bound to a section type and flagged either “one per unit” or “one per section”.
  5. Controls by rule, not by memory ahu-configurator-logic.ts answers the questions that used to depend on an engineer's recall: control panel material follows motor power and phase count plus total electric heating load; valve actuator type follows the presence of a water coil (which forces spring return) and of recirculation; damper count follows cross-sectional area.
  6. Price and document BOM lines are joined to component prices, and the result goes out as PDF — quote, invoice, calculation report — and as an order that then lives on in the workspace and in 1C.

The selection rules were not invented from scratch. They were recovered from the legacy configurator: its client-side JavaScript and HTML templates were read through, every function mapped to a new module, and the result written up as a migration register — what was ported, exactly where to, and what was deliberately left behind. Without that register, “porting the business logic” becomes a set of guesses that surface six months later on a live order.

05 · Duct fittings

62 fitting types described by parameters, not by pictures

The other half of the product line is ducts and fittings made to size. In src/lib/duct-3d/ every type is a TypeScript interface: a set of parameters carrying the same letters as the shop drawing (A width, B height, L length, R bend radius, α angle), plus a connection type on each end — TDC, flange, slide-on rail, cap, mesh.

There are 62 of them: round and rectangular straight runs, bends, concentric and eccentric reducers, tees and trouser pieces, crosses, offsets, caps, eight kinds of saddle tap, cowls, deflectors, nipples, couplings, flanges, grease traps, and insulated variants of each shape.

The preview is built with React Three Fiber straight from those parameters: change a number in a field and the geometry is rebuilt. A separate DimensionLabels layer (693 lines) draws dimension lines and leaders, because what an engineer needs is not a pretty render but a drawing that proves the right thing was ordered.

The same parametric record is reused downstream: it feeds sheet metal nesting and material take-off, the bill of materials, and 3D load planning for transport — because the fitting is represented as numbers rather than as a model file.

06 · Reference data and bulk operations

Dangerous actions do nothing by default

The configurator lives on equipment reference tables, and those tables arrive as exports: a supplier's spreadsheet, a price list, an update from 1C. Hence three tools in the back office that look dull and save the most time: bulk operations across 21 equipment tables, a diff of two imports broken down into added / removed / modified with a per-field list of changes, and an integrity check — which records ended up with no price, no GUID, or no link into the component price table.

Bulk linking of equipment to prices: dry run by default src/app/api/admin/configurator/bulk/route.ts
// Flag absent means count only. You get to make this mistake once,
// so it had better be the mistake where nothing happened.
const dryRun = params.dryRun ?? true;

async function autoLinkByGuid(tables?: string[], dryRun = true) {
  for (const table of tablesToProcess) {
    // rows that carry a 1C GUID but have no price linked to them
    const unlinked = await (prisma as any)[table].findMany({
      where: { guid: { not: null }, componentPriceId: null },
      select: { id: true, guid: true },
    });

    const prices = await prisma.componentPrice.findMany({
      where: { guid: { in: unlinked.map((u) => u.guid) } },
      select: { id: true, guid: true },
    });
    const priceMap = new Map(prices.map((p) => [p.guid, p.id]));

    for (const item of unlinked) {
      const priceId = priceMap.get(item.guid);
      if (!priceId) continue;
      if (!dryRun) await (prisma as any)[table].update({
        where: { id: item.id }, data: { componentPriceId: priceId },
      });
      linkedCount++;                // counted in both modes
    }
    details.push(`${table}: ${linkedCount} rows linked`);
  }
}

The counter increments during the dry run too, so the administrator first sees an exact report — “412 rows in the fan table will be linked” — and only then decides whether to commit. Linking goes by the accounting system's GUID, not by name: names in price lists are written however the supplier feels that day, and the GUID is the only thing that survives a rename. Linking by name exists as a separate action, but it comes second and always asks for confirmation.

07 · Sync with 1C

The item catalogue belongs to the accounting system; the portal mirrors it

The important decision here was ownership of the data. The answer: 1C owns it. The portal does not create items, does not assign codes and does not edit prices — it keeps a mirror and subscribes to changes.

Reads go over OData: first the folder hierarchy (folders flagged as groups unfold into categories and subcategories), then the item catalogue, attached to categories through Parent_Key. Items whose parent could not be resolved are not dropped — they fall into a separate bucket category where they stay visible and can be sorted out by hand.

Writes come the other way, through webhooks from 1C: five event types (price, stock, item update, deletion, order status), up to 5,000 items per request, field validation before the first database call, bearer token authorisation.

The key detail is the level a price arrives at. In 1C, price and stock belong to an item's characteristic — that is, to one concrete size — not to the item card. In the portal's schema that is a ProductVariant, and the webhook updates exactly that, keyed by the characteristic's GUID. An order status change travels the same path and reaches the customer as a Telegram notification.

1C webhook: event type, batch ceiling, and updates keyed by characteristic GUID src/app/api/1c/webhook/product-update/route.ts
interface WebhookPayload {
  event_type: 'price_update' | 'stock_update' | 'product_update'
            | 'product_delete' | 'order_update';
  timestamp: string;
  products: WebhookProduct[];
}

// Validate before the first database call: an empty batch and a 50,000-item batch
// are equally harmful, and both get rejected here.
if (!Array.isArray(payload.products) || payload.products.length === 0)
  return { valid: false, error: 'products must be a non-empty array' };
if (payload.products.length > 5000)
  return { valid: false, error: 'Maximum 5000 products per request' };
if (!item.ref_key)
  return { valid: false, error: `Missing required field: ref_key at index ${i}` };

// Price arrives against an item characteristic, not against the item card:
// in the portal's schema that is a ProductVariant, found by its 1C GUID.
await prisma.productVariant.update({
  where: { ref1C: p.characteristic_ref },
  data:  { price: p.price, priceOld: p.price_old },
});

// Stock lands in the same place, and the availability flag is derived from
// the quantity so that "in stock" and "0 pcs" can never disagree.
await prisma.productVariant.update({
  where: { ref1C: p.characteristic_ref },
  data:  { stockQty: p.stock_qty, inStock: (p.stock_qty || 0) > 0 },
});

inStock never arrives from outside — it is computed from the quantity. That is the only way to guarantee the storefront will not show “in stock” against zero inventory: two fields updated independently drifting apart is the classic cause of cancelled orders.

08 · What you can open

Two pieces were lifted out of the portal and run on their own

The portal itself does not start without a database, a search engine and 1C access — it cannot be shown as a link. But the calculation layer was written not to depend on infrastructure, and that had a direct consequence: two pieces detach from the portal and stand alone. These are live pages, not screen recordings.

Both pages are published out of this same project and may not be deployed yet at the moment you read this.

09 · Numbers

Counted in the source, not estimated

974

TypeScript files under src/

214,974

lines of code

206

App Router pages

154

API routes

125

Prisma models

44

equipment reference tables

LayerFilesLinesWhat is inside
Storefront (site)200100,877catalogue, facets, selection wizard, cart, checkout
Admin configurator (admin)8515,71221 sections, of which 44 equipment reference tables
Workspace (workspace)262,968orders, quotes, sites, installations, helpdesk, analytics
API routes156 (154 route.ts)16,262catalogue, configurator, 1C webhooks, Telegram, PDF
Server actions604,743cart, orders, calculations, quick order
Components28540,351catalogue, configurator, 3D, charts, forms
Calculation core lib/engineering2513,227sections, selection, validation, BOM, pricing
Calculators lib/calculators122,391aerodynamics, psychrometrics, climate, code limits
Duct fitting 3D lib/duct-3d95,34162 fitting types, meshes, dimension lines
Data model12,802125 models, 15 enums, 29 migrations

What got faster

Selecting an air handling unit no longer requires an engineer in the loop: the path “air flow → shortlist of casing sizes → section train → bill of materials with controls → quote” runs end to end in the browser. Two people and an email thread used to stand on that route.

Quick order removes manual entry: paste a list of part numbers straight out of an email, or drop an Excel/CSV file — lines are matched against the item catalogue and land in the cart as a batch. For repeat purchasing on a site that is the difference between twenty minutes and one paste.

What got simpler

Selection rules stopped being oral tradition. 18 topology checks and the controls-selection logic sit in two modules that can be read, argued with and changed — instead of being reconstructed from an engineer's memory or from someone else's script.

Updating reference data stopped being risky: a diff of two imports shows exactly what changed before anything is applied, and bulk operations run in counting mode unless told otherwise.

10 · What is off camera

Limits I know about and am not hiding

The fair summary is this: the engineering half — selection, validation, bill of materials, the 1C exchange — is built and working; the perimeter around it (tests, a unified design, observability of the exchange) trails a step behind. For a portal whose job is to replace an email thread with a sales manager that is the right order of priorities, but not an indefinite one: the next growth of the reference tables is where the perimeter gives first.