diff --git a/backend/src/db/api/inventoryContext.js b/backend/src/db/api/inventoryContext.js new file mode 100644 index 0000000..cc15467 --- /dev/null +++ b/backend/src/db/api/inventoryContext.js @@ -0,0 +1,398 @@ +const db = require('../models'); + +const QueryTypes = db.Sequelize.QueryTypes; +const Op = db.Sequelize.Op; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const getCurrentOrganizationId = (currentUser) => { + if (!currentUser) { + return null; + } + + return ( + currentUser.organization?.id || + currentUser.organizations?.id || + currentUser.organizationId || + currentUser.organizationsId || + null + ); +}; + +const createBadRequestError = (message) => { + const error = new Error(message); + error.code = 400; + return error; +}; + +const getImportCellValue = (item, keys) => { + for (const key of keys) { + const value = item?.[key]; + + if (value === 0 || value === false) { + return value; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed) { + return trimmed; + } + continue; + } + + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + + return null; +}; + +const normalizeReferenceValue = (value) => { + if (value === undefined || value === null) { + return null; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || null; + } + + return value; +}; + +const buildReferenceClauses = (reference, fields = []) => { + const normalizedReference = normalizeReferenceValue(reference); + + if (!normalizedReference) { + return []; + } + + const clauses = []; + + if (typeof normalizedReference === 'string' && UUID_PATTERN.test(normalizedReference)) { + clauses.push({ id: normalizedReference }); + } + + for (const field of fields) { + clauses.push({ + [field]: { + [Op.iLike]: String(normalizedReference), + }, + }); + } + + return clauses; +}; + +const buildLookupWhere = (reference, fields = [], extraWhere = {}) => { + const clauses = buildReferenceClauses(reference, fields); + + if (!clauses.length) { + return null; + } + + const filters = [{ [Op.or]: clauses }]; + + if (extraWhere && Object.keys(extraWhere).length) { + filters.unshift(extraWhere); + } + + if (filters.length === 1) { + return filters[0]; + } + + return { + [Op.and]: filters, + }; +}; + +const lookupSingleRecordByReference = async ({ + model, + label, + reference, + fields = [], + extraWhere = {}, + transaction, +}) => { + const normalizedReference = normalizeReferenceValue(reference); + + if (!normalizedReference) { + return null; + } + + const where = buildLookupWhere(normalizedReference, fields, extraWhere); + + if (!where) { + return null; + } + + const records = await model.findAll({ + where, + transaction, + limit: 2, + order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']], + }); + + if (!records.length) { + throw createBadRequestError(`${label} "${normalizedReference}" was not found.`); + } + + if (records.length > 1) { + throw createBadRequestError(`${label} "${normalizedReference}" matches multiple records. Use the ID instead.`); + } + + return records[0]; +}; + +const normalizeImportedBoolean = (value) => { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (['true', '1', 'yes', 'y'].includes(normalized)) { + return true; + } + if (['false', '0', 'no', 'n'].includes(normalized)) { + return false; + } + } + + return value; +}; + +const normalizeInventoryImportRow = (item = {}) => ({ + ...item, + id: getImportCellValue(item, ['id']) || undefined, + importHash: getImportCellValue(item, ['importHash']) || null, + tenant: getImportCellValue(item, ['tenant', 'tenantId']), + organizations: getImportCellValue(item, ['organizations', 'organization', 'organizationsId', 'organizationId']), + property: getImportCellValue(item, ['property', 'propertyId']), + unit_type: getImportCellValue(item, ['unit_type', 'unitType', 'unit_typeId', 'unitTypeId']), + is_active: normalizeImportedBoolean(item?.is_active), +}); + +const prefixImportError = (error, rowNumber) => { + if (!error) { + return error; + } + + const prefixedMessage = `Import row ${rowNumber}: ${error.message}`; + + if (typeof error.message === 'string' && !error.message.startsWith(`Import row ${rowNumber}:`)) { + error.message = prefixedMessage; + } + + return error; +}; + +const resolveTenantIdForOrganization = async (organizationId, transaction) => { + if (!organizationId) { + return null; + } + + const rows = await db.sequelize.query( + ` + SELECT "tenants_organizationsId" AS "tenantId" + FROM "tenantsOrganizationsOrganizations" + WHERE "organizationId" = :organizationId + ORDER BY "updatedAt" DESC, "createdAt" DESC + LIMIT 1 + `, + { + replacements: { organizationId }, + type: QueryTypes.SELECT, + transaction, + }, + ); + + return rows[0]?.tenantId || null; +}; + +const resolveOrganizationIdsForTenant = async (tenantId, transaction) => { + if (!tenantId) { + return []; + } + + const rows = await db.sequelize.query( + ` + SELECT "organizationId" + FROM "tenantsOrganizationsOrganizations" + WHERE "tenants_organizationsId" = :tenantId + ORDER BY "updatedAt" DESC, "createdAt" DESC + `, + { + replacements: { tenantId }, + type: QueryTypes.SELECT, + transaction, + }, + ); + + return rows.map((row) => row.organizationId).filter(Boolean); +}; + +const resolveTenantReference = async (reference, transaction) => { + const tenant = await lookupSingleRecordByReference({ + model: db.tenants, + label: 'Tenant', + reference, + fields: ['slug', 'name'], + transaction, + }); + + return tenant?.id || null; +}; + +const resolveOrganizationReference = async (reference, { tenantId, transaction } = {}) => { + const normalizedReference = normalizeReferenceValue(reference); + + if (!normalizedReference) { + return null; + } + + const extraWhere = {}; + + if (tenantId) { + const organizationIds = await resolveOrganizationIdsForTenant(tenantId, transaction); + + if (!organizationIds.length) { + throw createBadRequestError('The selected tenant has no organizations available for import.'); + } + + extraWhere.id = { + [Op.in]: organizationIds, + }; + } + + const organization = await lookupSingleRecordByReference({ + model: db.organizations, + label: 'Organization', + reference: normalizedReference, + fields: ['name'], + extraWhere, + transaction, + }); + + return organization?.id || null; +}; + +const resolvePropertyReference = async (reference, { currentUser, organizationId, tenantId, transaction } = {}) => { + const normalizedReference = normalizeReferenceValue(reference); + + if (!normalizedReference) { + return null; + } + + const globalAccess = Boolean(currentUser?.app_role?.globalAccess); + const extraWhere = {}; + + if (organizationId) { + extraWhere.organizationsId = organizationId; + } else if (!globalAccess) { + const currentOrganizationId = getCurrentOrganizationId(currentUser); + + if (currentOrganizationId) { + extraWhere.organizationsId = currentOrganizationId; + } + } + + if (tenantId) { + extraWhere.tenantId = tenantId; + } + + const property = await lookupSingleRecordByReference({ + model: db.properties, + label: 'Property', + reference: normalizedReference, + fields: ['code', 'name'], + extraWhere, + transaction, + }); + + return property?.id || null; +}; + +const resolveUnitTypeReference = async (reference, { currentUser, organizationId, propertyId, transaction } = {}) => { + const normalizedReference = normalizeReferenceValue(reference); + + if (!normalizedReference) { + return null; + } + + const globalAccess = Boolean(currentUser?.app_role?.globalAccess); + const extraWhere = {}; + + if (propertyId) { + extraWhere.propertyId = propertyId; + } + + if (organizationId) { + extraWhere.organizationsId = organizationId; + } else if (!globalAccess) { + const currentOrganizationId = getCurrentOrganizationId(currentUser); + + if (currentOrganizationId) { + extraWhere.organizationsId = currentOrganizationId; + } + } + + const unitType = await lookupSingleRecordByReference({ + model: db.unit_types, + label: 'Unit type', + reference: normalizedReference, + fields: ['code', 'name'], + extraWhere, + transaction, + }); + + return unitType?.id || null; +}; + +const loadPropertyContext = async (propertyId, transaction) => { + if (!propertyId) { + return null; + } + + const property = await db.properties.findByPk(propertyId, { transaction }); + + if (!property) { + throw createBadRequestError('Selected property was not found.'); + } + + return property; +}; + +const loadUnitTypeContext = async (unitTypeId, transaction) => { + if (!unitTypeId) { + return null; + } + + const unitType = await db.unit_types.findByPk(unitTypeId, { transaction }); + + if (!unitType) { + throw createBadRequestError('Selected unit type was not found.'); + } + + return unitType; +}; + +module.exports = { + createBadRequestError, + getCurrentOrganizationId, + getImportCellValue, + loadPropertyContext, + loadUnitTypeContext, + normalizeImportedBoolean, + normalizeInventoryImportRow, + normalizeReferenceValue, + prefixImportError, + resolveOrganizationIdsForTenant, + resolveOrganizationReference, + resolvePropertyReference, + resolveTenantIdForOrganization, + resolveTenantReference, + resolveUnitTypeReference, +}; diff --git a/backend/src/db/api/organizations.js b/backend/src/db/api/organizations.js index 7bf79e2..2101470 100644 --- a/backend/src/db/api/organizations.js +++ b/backend/src/db/api/organizations.js @@ -1,10 +1,7 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); - - +const { resolveOrganizationIdsForTenant } = require('./inventoryContext'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; @@ -74,8 +71,6 @@ module.exports = class OrganizationsDBApi { static async update(id, data, options) { const currentUser = (options && options.currentUser) || {id: null}; const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - const organizations = await db.organizations.findByPk(id, {}, {transaction}); @@ -322,9 +317,6 @@ module.exports = class OrganizationsDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ @@ -431,17 +423,30 @@ module.exports = class OrganizationsDBApi { } } - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - + static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, tenantId, options = {}) { + const filters = []; + const organizationIdsForTenant = tenantId + ? await resolveOrganizationIdsForTenant(Utils.uuid(tenantId), options.transaction) + : []; + if (!globalAccess && organizationId) { - where.organizationId = organizationId; + filters.push({ id: organizationId }); + } + + if (tenantId) { + if (!organizationIdsForTenant.length) { + return []; + } + + filters.push({ + id: { + [Op.in]: organizationIdsForTenant, + }, + }); } - if (query) { - where = { + filters.push({ [Op.or]: [ { ['id']: Utils.uuid(query) }, Utils.ilike( @@ -450,15 +455,19 @@ module.exports = class OrganizationsDBApi { query, ), ], - }; + }); } + const where = filters.length > 1 + ? { [Op.and]: filters } + : (filters[0] || {}); + const records = await db.organizations.findAll({ attributes: [ 'id', 'name' ], where, limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], + order: [['name', 'ASC']], }); return records.map((record) => ({ diff --git a/backend/src/db/api/properties.js b/backend/src/db/api/properties.js index 4c5f83a..1badc7e 100644 --- a/backend/src/db/api/properties.js +++ b/backend/src/db/api/properties.js @@ -2,8 +2,7 @@ const db = require('../models'); const FileDBApi = require('./file'); const Utils = require('../utils'); - - +const { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, prefixImportError, resolveOrganizationReference, resolveTenantIdForOrganization, resolveTenantReference } = require('./inventoryContext'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; @@ -15,6 +14,17 @@ module.exports = class PropertiesDBApi { static async create(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + let organizationId = data.organizations || null; + if (!organizationId && !globalAccess) { + organizationId = getCurrentOrganizationId(currentUser); + } + + let tenantId = data.tenant || null; + if (!tenantId && organizationId) { + tenantId = await resolveTenantIdForOrganization(organizationId, transaction); + } const properties = await db.properties.create( { @@ -69,11 +79,11 @@ module.exports = class PropertiesDBApi { ); - await properties.setTenant( data.tenant || null, { + await properties.setTenant( tenantId || null, { transaction, }); - await properties.setOrganizations( data.organizations || null, { + await properties.setOrganizations( organizationId || null, { transaction, }); @@ -111,77 +121,90 @@ module.exports = class PropertiesDBApi { static async bulkImport(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; + const seenIds = new Set(); + const seenImportHashes = new Set(); + const createdRecords = []; - // Prepare data - wrapping individual data transformations in a map() method - const propertiesData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - code: item.code - || - null - , - - address: item.address - || - null - , - - city: item.city - || - null - , - - country: item.country - || - null - , - - timezone: item.timezone - || - null - , - - description: item.description - || - null - , - - is_active: item.is_active - || - false - - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); + for (let index = 0; index < data.length; index += 1) { + const item = normalizeInventoryImportRow(data[index]); + const rowNumber = index + 2; - // Bulk create items - const properties = await db.properties.bulkCreate(propertiesData, { transaction }); + try { + const globalAccess = Boolean(currentUser.app_role?.globalAccess); + let tenantId = null; + let organizationId = null; - // For each item created, replace relation files - - for (let i = 0; i < properties.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.properties.getTableName(), - belongsToColumn: 'images', - belongsToId: properties[i].id, - }, - data[i].images, - options, - ); + if (globalAccess) { + tenantId = await resolveTenantReference(item.tenant, transaction); + organizationId = await resolveOrganizationReference(item.organizations, { + tenantId, + transaction, + }); + + if (!organizationId) { + throw createBadRequestError('Organization is required for property import.'); + } + } else { + organizationId = getCurrentOrganizationId(currentUser); + } + + if (!tenantId && organizationId) { + tenantId = await resolveTenantIdForOrganization(organizationId, transaction); + } + + item.tenant = tenantId || null; + item.organizations = organizationId || null; + + const itemId = item.id || null; + const importHash = item.importHash || null; + if (options?.ignoreDuplicates) { + if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) { + continue; + } + + const duplicateFilters = []; + if (itemId) { + duplicateFilters.push({ id: itemId }); + } + if (importHash) { + duplicateFilters.push({ importHash }); + } + + if (duplicateFilters.length) { + const existingRecord = await db.properties.findOne({ + where: { + [Op.or]: duplicateFilters, + }, + transaction, + paranoid: false, + }); + + if (existingRecord) { + continue; + } + } + } + + if (itemId) { + seenIds.add(itemId); + } + if (importHash) { + seenImportHashes.add(importHash); + } + + const createdRecord = await this.create(item, { + ...options, + currentUser, + transaction, + }); + + createdRecords.push(createdRecord); + } catch (error) { + throw prefixImportError(error, rowNumber); + } } - - return properties; + return createdRecords; } static async update(id, data, options) { @@ -757,13 +780,21 @@ module.exports = class PropertiesDBApi { } } - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { + static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, tenantId, selectedOrganizationId) { const filters = []; if (!globalAccess && organizationId) { filters.push({ organizationsId: organizationId }); } + if (tenantId) { + filters.push({ tenantId: Utils.uuid(tenantId) }); + } + + if (selectedOrganizationId) { + filters.push({ organizationsId: Utils.uuid(selectedOrganizationId) }); + } + if (query) { filters.push({ [Op.or]: [ @@ -786,7 +817,7 @@ module.exports = class PropertiesDBApi { where, limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], + order: [['name', 'ASC']], }); return records.map((record) => ({ diff --git a/backend/src/db/api/unit_types.js b/backend/src/db/api/unit_types.js index 550d6a7..cad0bb5 100644 --- a/backend/src/db/api/unit_types.js +++ b/backend/src/db/api/unit_types.js @@ -1,8 +1,7 @@ const db = require('../models'); const Utils = require('../utils'); - - +const { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, loadPropertyContext, prefixImportError, resolvePropertyReference } = require('./inventoryContext'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; @@ -14,6 +13,17 @@ module.exports = class Unit_typesDBApi { static async create(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + if (!data.property) { + throw createBadRequestError('Select a property before creating a unit type.'); + } + + const property = await loadPropertyContext(data.property || null, transaction); + + let organizationId = data.organizations || property?.organizationsId || null; + if (!organizationId && !globalAccess) { + organizationId = getCurrentOrganizationId(currentUser); + } const unit_types = await db.unit_types.create( { @@ -81,7 +91,7 @@ module.exports = class Unit_typesDBApi { transaction, }); - await unit_types.setOrganizations( data.organizations || null, { + await unit_types.setOrganizations( organizationId || null, { transaction, }); @@ -101,84 +111,98 @@ module.exports = class Unit_typesDBApi { static async bulkImport(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; + const seenIds = new Set(); + const seenImportHashes = new Set(); + const createdRecords = []; - // Prepare data - wrapping individual data transformations in a map() method - const unit_typesData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - code: item.code - || - null - , - - max_occupancy: item.max_occupancy - || - null - , - - bedrooms: item.bedrooms - || - null - , - - bathrooms: item.bathrooms - || - null - , - - size_sqm: item.size_sqm - || - null - , - - description: item.description - || - null - , - - base_nightly_rate: item.base_nightly_rate - || - null - , - - base_monthly_rate: item.base_monthly_rate - || - null - , - - minimum_stay_nights: item.minimum_stay_nights - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); + for (let index = 0; index < data.length; index += 1) { + const item = normalizeInventoryImportRow(data[index]); + const rowNumber = index + 2; - // Bulk create items - const unit_types = await db.unit_types.bulkCreate(unit_typesData, { transaction }); + try { + const propertyId = await resolvePropertyReference(item.property, { + currentUser, + transaction, + }); - // For each item created, replace relation files - + if (!propertyId) { + throw createBadRequestError('Property is required for unit type import.'); + } - return unit_types; + item.property = propertyId; + + const itemId = item.id || null; + const importHash = item.importHash || null; + if (options?.ignoreDuplicates) { + if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) { + continue; + } + + const duplicateFilters = []; + if (itemId) { + duplicateFilters.push({ id: itemId }); + } + if (importHash) { + duplicateFilters.push({ importHash }); + } + + if (duplicateFilters.length) { + const existingRecord = await db.unit_types.findOne({ + where: { + [Op.or]: duplicateFilters, + }, + transaction, + paranoid: false, + }); + + if (existingRecord) { + continue; + } + } + } + + if (itemId) { + seenIds.add(itemId); + } + if (importHash) { + seenImportHashes.add(importHash); + } + + const createdRecord = await this.create(item, { + ...options, + currentUser, + transaction, + }); + + createdRecords.push(createdRecord); + } catch (error) { + throw prefixImportError(error, rowNumber); + } + } + + return createdRecords; } static async update(id, data, options) { const currentUser = (options && options.currentUser) || {id: null}; const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; const unit_types = await db.unit_types.findByPk(id, {}, {transaction}); + const nextPropertyId = data.property !== undefined ? data.property : unit_types?.propertyId; + if (!nextPropertyId) { + throw createBadRequestError('Select a property before saving this unit type.'); + } + const property = await loadPropertyContext(nextPropertyId || null, transaction); - + let organizationId = data.organizations; + if (organizationId === undefined && data.property !== undefined) { + organizationId = property?.organizationsId || null; + } + if ((organizationId === undefined || organizationId === null) && !globalAccess && !unit_types?.organizationsId) { + organizationId = getCurrentOrganizationId(currentUser); + } const updatePayload = {}; @@ -227,10 +251,10 @@ module.exports = class Unit_typesDBApi { ); } - if (data.organizations !== undefined) { + if (organizationId !== undefined) { await unit_types.setOrganizations( - data.organizations, + organizationId, { transaction } ); @@ -763,13 +787,17 @@ module.exports = class Unit_typesDBApi { } } - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { + static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, propertyId) { const filters = []; if (!globalAccess && organizationId) { filters.push({ organizationsId: organizationId }); } + if (propertyId) { + filters.push({ propertyId: Utils.uuid(propertyId) }); + } + if (query) { filters.push({ [Op.or]: [ @@ -792,7 +820,7 @@ module.exports = class Unit_typesDBApi { where, limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], + order: [['name', 'ASC']], }); return records.map((record) => ({ diff --git a/backend/src/db/api/units.js b/backend/src/db/api/units.js index 5091f29..132803d 100644 --- a/backend/src/db/api/units.js +++ b/backend/src/db/api/units.js @@ -1,10 +1,7 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); - - +const { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, loadPropertyContext, loadUnitTypeContext, prefixImportError, resolvePropertyReference, resolveUnitTypeReference } = require('./inventoryContext'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; @@ -16,6 +13,31 @@ module.exports = class UnitsDBApi { static async create(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + let propertyId = data.property || null; + const unitType = await loadUnitTypeContext(data.unit_type || null, transaction); + if (!propertyId && unitType?.propertyId) { + propertyId = unitType.propertyId; + } + + if (!propertyId) { + throw createBadRequestError('Select a property before creating a unit.'); + } + if (!data.unit_type) { + throw createBadRequestError('Select a unit type before creating a unit.'); + } + + const property = await loadPropertyContext(propertyId || null, transaction); + + if (property && unitType?.propertyId && unitType.propertyId !== property.id) { + throw createBadRequestError('Selected unit type does not belong to the selected property.'); + } + + let organizationId = data.organizations || property?.organizationsId || unitType?.organizationsId || null; + if (!organizationId && !globalAccess) { + organizationId = getCurrentOrganizationId(currentUser); + } const units = await db.units.create( { @@ -54,7 +76,7 @@ module.exports = class UnitsDBApi { ); - await units.setProperty( data.property || null, { + await units.setProperty( propertyId || null, { transaction, }); @@ -62,7 +84,7 @@ module.exports = class UnitsDBApi { transaction, }); - await units.setOrganizations( data.organizations || null, { + await units.setOrganizations( organizationId || null, { transaction, }); @@ -82,49 +104,89 @@ module.exports = class UnitsDBApi { static async bulkImport(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; + const seenIds = new Set(); + const seenImportHashes = new Set(); + const createdRecords = []; - // Prepare data - wrapping individual data transformations in a map() method - const unitsData = data.map((item, index) => ({ - id: item.id || undefined, - - unit_number: item.unit_number - || - null - , - - floor: item.floor - || - null - , - - status: item.status - || - null - , - - max_occupancy_override: item.max_occupancy_override - || - null - , - - notes: item.notes - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); + for (let index = 0; index < data.length; index += 1) { + const item = normalizeInventoryImportRow(data[index]); + const rowNumber = index + 2; - // Bulk create items - const units = await db.units.bulkCreate(unitsData, { transaction }); + try { + const propertyId = await resolvePropertyReference(item.property, { + currentUser, + transaction, + }); - // For each item created, replace relation files - + if (!propertyId) { + throw createBadRequestError('Property is required for unit import.'); + } - return units; + const property = await loadPropertyContext(propertyId, transaction); + const unitTypeId = await resolveUnitTypeReference(item.unit_type, { + currentUser, + organizationId: property?.organizationsId || null, + propertyId, + transaction, + }); + + if (!unitTypeId) { + throw createBadRequestError('Unit type is required for unit import.'); + } + + item.property = propertyId; + item.unit_type = unitTypeId; + + const itemId = item.id || null; + const importHash = item.importHash || null; + if (options?.ignoreDuplicates) { + if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) { + continue; + } + + const duplicateFilters = []; + if (itemId) { + duplicateFilters.push({ id: itemId }); + } + if (importHash) { + duplicateFilters.push({ importHash }); + } + + if (duplicateFilters.length) { + const existingRecord = await db.units.findOne({ + where: { + [Op.or]: duplicateFilters, + }, + transaction, + paranoid: false, + }); + + if (existingRecord) { + continue; + } + } + } + + if (itemId) { + seenIds.add(itemId); + } + if (importHash) { + seenImportHashes.add(importHash); + } + + const createdRecord = await this.create(item, { + ...options, + currentUser, + transaction, + }); + + createdRecords.push(createdRecord); + } catch (error) { + throw prefixImportError(error, rowNumber); + } + } + + return createdRecords; } static async update(id, data, options) { @@ -133,9 +195,30 @@ module.exports = class UnitsDBApi { const globalAccess = currentUser.app_role?.globalAccess; const units = await db.units.findByPk(id, {}, {transaction}); + const nextPropertyId = data.property !== undefined ? data.property : units?.propertyId; + const nextUnitTypeId = data.unit_type !== undefined ? data.unit_type : units?.unit_typeId; + const unitType = await loadUnitTypeContext(nextUnitTypeId || null, transaction); + const propertyId = nextPropertyId || unitType?.propertyId || null; + if (!propertyId) { + throw createBadRequestError('Select a property before saving this unit.'); + } + if (!nextUnitTypeId) { + throw createBadRequestError('Select a unit type before saving this unit.'); + } + const property = await loadPropertyContext(propertyId || null, transaction); - + if (property && unitType?.propertyId && unitType.propertyId !== property.id) { + throw createBadRequestError('Selected unit type does not belong to the selected property.'); + } + + let organizationId = data.organizations; + if (organizationId === undefined && (data.property !== undefined || data.unit_type !== undefined)) { + organizationId = property?.organizationsId || unitType?.organizationsId || null; + } + if ((organizationId === undefined || organizationId === null) && !globalAccess && !units?.organizationsId) { + organizationId = getCurrentOrganizationId(currentUser); + } const updatePayload = {}; @@ -160,10 +243,10 @@ module.exports = class UnitsDBApi { - if (data.property !== undefined) { + if (data.property !== undefined || (data.unit_type !== undefined && propertyId !== units?.propertyId)) { await units.setProperty( - data.property, + propertyId, { transaction } ); @@ -178,10 +261,10 @@ module.exports = class UnitsDBApi { ); } - if (data.organizations !== undefined) { + if (organizationId !== undefined) { await units.setOrganizations( - data.organizations, + organizationId, { transaction } ); @@ -349,9 +432,6 @@ module.exports = class UnitsDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ @@ -597,17 +677,23 @@ module.exports = class UnitsDBApi { } } - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - + static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, propertyId, unitTypeId) { + const filters = []; if (!globalAccess && organizationId) { - where.organizationId = organizationId; + filters.push({ organizationsId: organizationId }); } + if (propertyId) { + filters.push({ propertyId: Utils.uuid(propertyId) }); + } + + if (unitTypeId) { + filters.push({ unit_typeId: Utils.uuid(unitTypeId) }); + } if (query) { - where = { + filters.push({ [Op.or]: [ { ['id']: Utils.uuid(query) }, Utils.ilike( @@ -616,15 +702,19 @@ module.exports = class UnitsDBApi { query, ), ], - }; + }); } + const where = filters.length > 1 + ? { [Op.and]: filters } + : (filters[0] || {}); + const records = await db.units.findAll({ attributes: [ 'id', 'unit_number' ], where, limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, - orderBy: [['unit_number', 'ASC']], + order: [['unit_number', 'ASC']], }); return records.map((record) => ({ diff --git a/backend/src/routes/organizations.js b/backend/src/routes/organizations.js index 6f96a1d..e51987d 100644 --- a/backend/src/routes/organizations.js +++ b/backend/src/routes/organizations.js @@ -5,8 +5,6 @@ const OrganizationsService = require('../services/organizations'); const OrganizationsDBApi = require('../db/api/organizations'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); - const router = express.Router(); @@ -377,17 +375,16 @@ router.get('/count', wrapAsync(async (req, res) => { * description: Some server error */ router.get('/autocomplete', async (req, res) => { - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - + const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId; + const payload = await OrganizationsDBApi.findAllAutocomplete( req.query.query, req.query.limit, req.query.offset, - globalAccess, organizationId, + globalAccess, + organizationId, + req.query.tenantId || req.query.tenant, ); res.status(200).send(payload); diff --git a/backend/src/routes/properties.js b/backend/src/routes/properties.js index c43d894..fb820fd 100644 --- a/backend/src/routes/properties.js +++ b/backend/src/routes/properties.js @@ -8,6 +8,7 @@ const wrapAsync = require('../helpers').wrapAsync; const router = express.Router(); const { parse } = require('json2csv'); +const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates'); const { @@ -17,7 +18,7 @@ const { router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id; + const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId; const payload = await PropertiesDBApi.findAllAutocomplete( req.query.query, @@ -25,6 +26,8 @@ router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (re req.query.offset, globalAccess, organizationId, + req.query.tenantId || req.query.tenant, + req.query.organizationsId || req.query.organizationId || req.query.organizations, ); res.status(200).send(payload); @@ -150,6 +153,15 @@ router.post('/', wrapAsync(async (req, res) => { * description: Some server error * */ +router.get('/import-template', wrapAsync(async (req, res) => { + const template = getInventoryImportTemplate('properties', req.currentUser); + const csv = buildInventoryImportTemplateCsv('properties', req.currentUser); + + res.status(200); + res.attachment(template.fileName); + res.send(csv); +})); + router.post('/bulk-import', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); diff --git a/backend/src/routes/unit_types.js b/backend/src/routes/unit_types.js index fc9c413..d1ef374 100644 --- a/backend/src/routes/unit_types.js +++ b/backend/src/routes/unit_types.js @@ -8,6 +8,7 @@ const wrapAsync = require('../helpers').wrapAsync; const router = express.Router(); const { parse } = require('json2csv'); +const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates'); const { @@ -17,7 +18,7 @@ const { router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id; + const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId; const payload = await Unit_typesDBApi.findAllAutocomplete( req.query.query, @@ -25,6 +26,7 @@ router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (re req.query.offset, globalAccess, organizationId, + req.query.propertyId || req.query.property, ); res.status(200).send(payload); @@ -159,6 +161,15 @@ router.post('/', wrapAsync(async (req, res) => { * description: Some server error * */ +router.get('/import-template', wrapAsync(async (req, res) => { + const template = getInventoryImportTemplate('unit_types', req.currentUser); + const csv = buildInventoryImportTemplateCsv('unit_types', req.currentUser); + + res.status(200); + res.attachment(template.fileName); + res.send(csv); +})); + router.post('/bulk-import', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); diff --git a/backend/src/routes/units.js b/backend/src/routes/units.js index 54c23cb..829e2aa 100644 --- a/backend/src/routes/units.js +++ b/backend/src/routes/units.js @@ -5,12 +5,11 @@ const UnitsService = require('../services/units'); const UnitsDBApi = require('../db/api/units'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); - const router = express.Router(); const { parse } = require('json2csv'); +const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates'); const { @@ -129,6 +128,15 @@ router.post('/', wrapAsync(async (req, res) => { * description: Some server error * */ +router.get('/import-template', wrapAsync(async (req, res) => { + const template = getInventoryImportTemplate('units', req.currentUser); + const csv = buildInventoryImportTemplateCsv('units', req.currentUser); + + res.status(200); + res.attachment(template.fileName); + res.send(csv); +})); + router.post('/bulk-import', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); @@ -387,17 +395,17 @@ router.get('/count', wrapAsync(async (req, res) => { * description: Some server error */ router.get('/autocomplete', async (req, res) => { - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - + const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId; + const payload = await UnitsDBApi.findAllAutocomplete( req.query.query, req.query.limit, req.query.offset, - globalAccess, organizationId, + globalAccess, + organizationId, + req.query.propertyId || req.query.property, + req.query.unitTypeId || req.query.unit_type || req.query.unitType, ); res.status(200).send(payload); diff --git a/backend/src/services/importer.js b/backend/src/services/importer.js new file mode 100644 index 0000000..86873c0 --- /dev/null +++ b/backend/src/services/importer.js @@ -0,0 +1,152 @@ +const csv = require('csv-parser'); +const stream = require('stream'); + +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); + +const ALLOWED_CSV_MIME_TYPES = new Set([ + 'text/csv', + 'application/csv', + 'application/vnd.ms-excel', + 'text/plain', + 'application/octet-stream', +]); + +const isCsvFilename = (filename = '') => filename.toLowerCase().endsWith('.csv'); + +const isBlankRow = (row = {}) => + Object.values(row).every((value) => value === undefined || value === null || String(value).trim() === ''); + +const createCsvValidationError = (message) => { + const error = new Error(message); + error.code = 400; + return error; +}; + +const splitCsvHeaderLine = (line = '') => { + const headers = []; + let current = ''; + let insideQuotes = false; + + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + const nextCharacter = line[index + 1]; + + if (character === '"') { + if (insideQuotes && nextCharacter === '"') { + current += '"'; + index += 1; + } else { + insideQuotes = !insideQuotes; + } + continue; + } + + if (character === ',' && !insideQuotes) { + headers.push(current.trim()); + current = ''; + continue; + } + + current += character; + } + + headers.push(current.trim()); + + return headers + .map((header) => header.replace(/^\uFEFF/, '').trim()) + .filter((header) => header.length); +}; + +const getCsvHeadersFromBuffer = (buffer) => { + const fileContents = Buffer.from(buffer).toString('utf8').replace(/^\uFEFF/, ''); + const firstNonEmptyLine = fileContents + .split(/\r?\n/) + .find((line) => line.trim().length > 0); + + if (!firstNonEmptyLine) { + return []; + } + + return splitCsvHeaderLine(firstNonEmptyLine); +}; + +const validateCsvHeaders = (headers = [], options = {}) => { + const allowedHeaders = (options.allowedHeaders || options.columns || []).map((header) => header.trim()); + const requiredHeaders = (options.requiredHeaders || []).map((header) => header.trim()); + + if (!allowedHeaders.length) { + return; + } + + const unknownHeaders = headers.filter((header) => !allowedHeaders.includes(header)); + const missingRequiredHeaders = requiredHeaders.filter((header) => !headers.includes(header)); + + if (!unknownHeaders.length && !missingRequiredHeaders.length) { + return; + } + + const details = []; + + if (missingRequiredHeaders.length) { + details.push(`Missing required columns: ${missingRequiredHeaders.join(', ')}`); + } + + if (unknownHeaders.length) { + details.push(`Unexpected columns: ${unknownHeaders.join(', ')}`); + } + + details.push(`Allowed columns: ${allowedHeaders.join(', ')}`); + + throw createCsvValidationError(details.join('. ')); +}; + +const parseCsvImportFile = async (req, res, options = {}) => { + await processFile(req, res); + + if (!req.file || !req.file.buffer || !req.file.buffer.length) { + throw new ValidationError('importer.errors.invalidFileEmpty'); + } + + const filename = req.file.originalname || req.body?.filename || ''; + const mimetype = req.file.mimetype || ''; + + if (!isCsvFilename(filename) && !ALLOWED_CSV_MIME_TYPES.has(mimetype)) { + throw new ValidationError('importer.errors.invalidFileUpload'); + } + + const csvHeaders = getCsvHeadersFromBuffer(req.file.buffer); + validateCsvHeaders(csvHeaders, options); + + const bufferStream = new stream.PassThrough(); + const results = []; + + bufferStream.end(Buffer.from(req.file.buffer)); + + await new Promise((resolve, reject) => { + bufferStream + .pipe( + csv({ + mapHeaders: ({ header }) => (header ? header.trim() : header), + mapValues: ({ value }) => (typeof value === 'string' ? value.trim() : value), + }), + ) + .on('data', (data) => { + if (!isBlankRow(data)) { + results.push(data); + } + }) + .on('end', resolve) + .on('error', reject); + }); + + if (!results.length) { + throw new ValidationError('importer.errors.invalidFileEmpty'); + } + + return results; +}; + +module.exports = { + parseCsvImportFile, +}; diff --git a/backend/src/services/inventoryImportTemplates.js b/backend/src/services/inventoryImportTemplates.js new file mode 100644 index 0000000..9157564 --- /dev/null +++ b/backend/src/services/inventoryImportTemplates.js @@ -0,0 +1,72 @@ +const { parse } = require('json2csv'); + +const getTemplateForProperties = (currentUser) => { + const globalAccess = Boolean(currentUser?.app_role?.globalAccess); + const columns = globalAccess + ? ['tenant', 'organization', 'name', 'code', 'address', 'city', 'country', 'timezone', 'description', 'is_active'] + : ['name', 'code', 'address', 'city', 'country', 'timezone', 'description', 'is_active']; + + return { + columns, + requiredColumns: globalAccess ? ['organization'] : [], + fileName: 'properties-import-template.csv', + }; +}; + +const getTemplateForUnitTypes = () => ({ + columns: [ + 'property', + 'name', + 'code', + 'description', + 'max_occupancy', + 'bedrooms', + 'bathrooms', + 'minimum_stay_nights', + 'size_sqm', + 'base_nightly_rate', + 'base_monthly_rate', + ], + requiredColumns: ['property'], + fileName: 'unit-types-import-template.csv', +}); + +const getTemplateForUnits = () => ({ + columns: [ + 'property', + 'unit_type', + 'unit_number', + 'floor', + 'status', + 'max_occupancy_override', + 'notes', + ], + requiredColumns: ['property', 'unit_type'], + fileName: 'units-import-template.csv', +}); + +const TEMPLATE_FACTORIES = { + properties: getTemplateForProperties, + unit_types: getTemplateForUnitTypes, + units: getTemplateForUnits, +}; + +const getInventoryImportTemplate = (entity, currentUser) => { + const factory = TEMPLATE_FACTORIES[entity]; + + if (!factory) { + throw new Error(`Unknown inventory import template entity: ${entity}`); + } + + return factory(currentUser); +}; + +const buildInventoryImportTemplateCsv = (entity, currentUser) => { + const template = getInventoryImportTemplate(entity, currentUser); + return parse([], { fields: template.columns }); +}; + +module.exports = { + buildInventoryImportTemplateCsv, + getInventoryImportTemplate, +}; diff --git a/backend/src/services/properties.js b/backend/src/services/properties.js index ccbd4c6..4d84d1d 100644 --- a/backend/src/services/properties.js +++ b/backend/src/services/properties.js @@ -1,11 +1,8 @@ const db = require('../db/models'); const PropertiesDBApi = require('../db/api/properties'); -const processFile = require("../middlewares/upload"); const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); +const { parseCsvImportFile } = require('./importer'); +const { getInventoryImportTemplate } = require('./inventoryImportTemplates'); @@ -28,28 +25,17 @@ module.exports = class PropertiesService { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) + const results = await parseCsvImportFile( + req, + res, + getInventoryImportTemplate('properties', req.currentUser), + ); await PropertiesDBApi.bulkImport(results, { transaction, @@ -95,7 +81,7 @@ module.exports = class PropertiesService { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -132,7 +118,6 @@ module.exports = class PropertiesService { } } - -}; +} diff --git a/backend/src/services/unit_types.js b/backend/src/services/unit_types.js index ba034a3..449c4a5 100644 --- a/backend/src/services/unit_types.js +++ b/backend/src/services/unit_types.js @@ -1,11 +1,8 @@ const db = require('../db/models'); const Unit_typesDBApi = require('../db/api/unit_types'); -const processFile = require("../middlewares/upload"); const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); +const { parseCsvImportFile } = require('./importer'); +const { getInventoryImportTemplate } = require('./inventoryImportTemplates'); @@ -28,28 +25,17 @@ module.exports = class Unit_typesService { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) + const results = await parseCsvImportFile( + req, + res, + getInventoryImportTemplate('unit_types', req.currentUser), + ); await Unit_typesDBApi.bulkImport(results, { transaction, @@ -95,7 +81,7 @@ module.exports = class Unit_typesService { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -132,7 +118,6 @@ module.exports = class Unit_typesService { } } - -}; +} diff --git a/backend/src/services/units.js b/backend/src/services/units.js index b4a984a..c333e2d 100644 --- a/backend/src/services/units.js +++ b/backend/src/services/units.js @@ -1,11 +1,8 @@ const db = require('../db/models'); const UnitsDBApi = require('../db/api/units'); -const processFile = require("../middlewares/upload"); const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); +const { parseCsvImportFile } = require('./importer'); +const { getInventoryImportTemplate } = require('./inventoryImportTemplates'); @@ -28,28 +25,17 @@ module.exports = class UnitsService { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) + const results = await parseCsvImportFile( + req, + res, + getInventoryImportTemplate('units', req.currentUser), + ); await UnitsDBApi.bulkImport(results, { transaction, @@ -95,7 +81,7 @@ module.exports = class UnitsService { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -132,7 +118,6 @@ module.exports = class UnitsService { } } - -}; +} diff --git a/frontend/src/components/InventoryImportGuidance.tsx b/frontend/src/components/InventoryImportGuidance.tsx new file mode 100644 index 0000000..698c23c --- /dev/null +++ b/frontend/src/components/InventoryImportGuidance.tsx @@ -0,0 +1,57 @@ +import { InventoryTemplateConfig } from '../helpers/inventoryImportTemplates'; + +type Props = { + config: InventoryTemplateConfig; +}; + +const InventoryImportGuidance = ({ config }: Props) => { + const exampleCsvHeader = config.columns.join(','); + const exampleCsvRow = config.columns + .map((column) => config.exampleRow.find((entry) => entry.column === column)?.value || '') + .join(','); + + return ( +
Use the import template for this workflow
+{config.workflowHint}
+{config.referenceHint}
++ Allowed columns: {config.columns.join(', ')} +
++ Required relationship columns:{' '} + {config.requiredColumns.length ? config.requiredColumns.join(', ') : 'None'} +
+ + {config.exampleReferences.length > 0 && ( +Reference examples
+
+ {example.value}
+
+ {example.note ? {example.note} : null}
+ Example row
++ Replace these example values with real records from your workspace before uploading. +
+
+ {`${exampleCsvHeader}
+${exampleCsvRow}`}
+
+