const admin = require("firebase-admin"); const client = require("../../../graphql-client/graphql-client").client; const DefaultNewShop = require("../defaultNewShop.json"); const { CHECK_EXTERNAL_SHOP_ID, CREATE_SHOP, DELETE_VENDORS_BY_SHOP, DELETE_SHOP, CREATE_USER, UPDATE_BODYSHOP_BY_ID } = require("../partsManagement.queries"); /** * Checks if the required fields are present in the payload. * @param payload * @param fields */ const requireFields = (payload, fields) => { for (const field of fields) { if (!payload[field]) { throw { status: 400, message: `${field} is required.` }; } } }; /** * Ensures that the provided email is not already registered in Firebase. * @param email * @returns {Promise} */ const ensureEmailNotRegistered = async (email) => { try { await admin.auth().getUserByEmail(email); throw { status: 400, message: "userEmail is already registered in Firebase." }; } catch (err) { if (err.code !== "auth/user-not-found") { throw { status: 500, message: "Error validating userEmail uniqueness", detail: err }; } } }; /** * Creates a new Firebase user with the given email and optional password. * @param email * @param password * @returns {Promise} */ const createFirebaseUser = async (email, password = null) => { const userData = { email }; if (password) userData.password = password; return admin.auth().createUser(userData); }; /** * Deletes a Firebase user by UID. * @param uid * @returns {Promise} */ const deleteFirebaseUser = async (uid) => { return admin.auth().deleteUser(uid); }; /** * Generates a password reset link for the given email. * @param email * @returns {Promise} */ const generateResetLink = async (email) => { return admin.auth().generatePasswordResetLink(email); }; /** * Ensures that the provided external shop ID is unique. * @param externalId * @returns {Promise} */ const ensureExternalIdUnique = async (externalId) => { const resp = await client.request(CHECK_EXTERNAL_SHOP_ID, { key: externalId }); return !!resp.bodyshops.length; }; /** * Inserts a new bodyshop into the database. * @param input * @returns {Promise<*>} */ const insertBodyshop = async (input) => { const resp = await client.request(CREATE_SHOP, { bs: input }); return resp.insert_bodyshops_one.id; }; /** * Deletes all vendors associated with a shop. * @param shopId * @returns {Promise} */ const deleteVendorsByShop = async (shopId) => { await client.request(DELETE_VENDORS_BY_SHOP, { shopId }); }; /** * Deletes a bodyshop from the database. * @param shopId * @returns {Promise} */ const deleteBodyshop = async (shopId) => { await client.request(DELETE_SHOP, { id: shopId }); }; /** * Inserts a new user association into the database. * @param uid * @param email * @param shopId * @returns {Promise<*>} */ const insertUserAssociation = async (uid, email, shopId) => { const vars = { u: { email, authid: uid, validemail: true, associations: { data: [{ shopid: shopId, authlevel: 99, active: true }] } } }; const resp = await client.request(CREATE_USER, vars); return resp.insert_users_one; }; /** * PATCH handler for updating bodyshop fields. * Allows patching: shopname, address1, address2, city, state, zip_post, country, email, timezone, phone * Also allows updating logo_img_path via a simple logoUrl string, which is expanded to the full object. * @param req * @param res * @returns {Promise} */ const patchPartsManagementProvisioning = async (req, res) => { const { id } = req.params; // Fields that can be directly patched 1:1 const allowedFields = [ "shopname", "address1", "address2", "city", "state", "zip_post", "country", "email", "timezone", "phone" // NOTE: logo_img_path is handled separately via logoUrl ]; const updateFields = {}; // Copy over simple scalar fields if present for (const field of allowedFields) { if (req.body[field] !== undefined) { updateFields[field] = req.body[field]; } } // Handle logo update via a simple href string, same behavior as provision route if (typeof req.body.logo_img_path === "string") { const trimmed = req.body.logo_img_path.trim(); if (trimmed) { updateFields.logo_img_path = { src: trimmed, width: "", height: "", headerMargin: DefaultNewShop.logo_img_path.headerMargin }; } } if (Object.keys(updateFields).length === 0) { return res.status(400).json({ error: "No valid fields provided for update." }); } // Check that the bodyshop has an external_shop_id before allowing patch try { const shopResp = await client.request( `query GetBodyshop($id: uuid!) { bodyshops_by_pk(id: $id) { id external_shop_id } }`, { id } ); if (!shopResp.bodyshops_by_pk?.external_shop_id) { return res.status(400).json({ error: "Cannot patch: bodyshop does not have an external_shop_id." }); } } catch (err) { return res.status(500).json({ error: "Failed to validate bodyshop external_shop_id.", detail: err }); } try { const resp = await client.request(UPDATE_BODYSHOP_BY_ID, { id, fields: updateFields }); if (!resp.update_bodyshops_by_pk) { return res.status(404).json({ error: "Bodyshop not found." }); } return res.json(resp.update_bodyshops_by_pk); } catch (err) { return res.status(500).json({ error: "Failed to update bodyshop.", detail: err }); } }; /** * Handles provisioning a new shop for parts management. * @param req * @param res * @returns {Promise<*>} */ const partsManagementProvisioning = async (req, res) => { const { logger } = req; // Trim and normalize email early const body = { ...req.body, userEmail: req.body.userEmail?.trim().toLowerCase() }; const trim = (value) => (typeof value === "string" ? value.trim() : value); const trimIfString = (value) => value !== null && value !== undefined && typeof value === "string" ? value.trim() : value; try { // Ensure email is present and trimmed before checking registration if (!body.userEmail) { throw { status: 400, message: "userEmail is required" }; } await ensureEmailNotRegistered(body.userEmail); requireFields(body, [ "external_shop_id", "shopname", "address1", "city", "state", "zip_post", "country", "email", "phone", "userEmail" ]); // Trim all top-level string fields const trimmedBody = { ...body, external_shop_id: trim(body.external_shop_id), shopname: trim(body.shopname), address1: trim(body.address1), address2: trimIfString(body.address2), city: trim(body.city), state: trim(body.state), zip_post: trim(body.zip_post), country: trim(body.country), email: trim(body.email), phone: trim(body.phone), timezone: trimIfString(body.timezone), logoUrl: trimIfString(body.logoUrl), userPassword: body.userPassword, // passwords should NOT be trimmed (preserves intentional spaces if any, though rare) vendors: Array.isArray(body.vendors) ? body.vendors.map((v) => ({ name: trim(v.name), street1: trimIfString(v.street1), street2: trimIfString(v.street2), city: trimIfString(v.city), state: trimIfString(v.state), zip: trimIfString(v.zip), country: trimIfString(v.country), email: trimIfString(v.email), cost_center: trimIfString(v.cost_center), phone: trimIfString(v.phone), dmsid: trimIfString(v.dmsid), discount: v.discount ?? 0, due_date: v.due_date ?? null, favorite: v.favorite ?? [], active: v.active ?? true })) : [] }; const duplicateCheck = await ensureExternalIdUnique(trimmedBody.external_shop_id); if (duplicateCheck) { throw { status: 400, message: `external_shop_id '${trimmedBody.external_shop_id}' is already in use.` }; } logger.log("admin-create-shop-user", "debug", trimmedBody.userEmail, null, { request: req.body, ioadmin: true }); const shopInput = { shopname: trimmedBody.shopname, address1: trimmedBody.address1, address2: trimmedBody.address2, city: trimmedBody.city, state: trimmedBody.state, zip_post: trimmedBody.zip_post, country: trimmedBody.country, email: trimmedBody.email, external_shop_id: trimmedBody.external_shop_id, timezone: trimmedBody.timezone || DefaultNewShop.timezone, phone: trimmedBody.phone, logo_img_path: { src: trimmedBody.logoUrl || null, // allow empty logo width: "", height: "", headerMargin: DefaultNewShop.logo_img_path.headerMargin }, features: { allAccess: false, partsManagementOnly: true }, md_ro_statuses: DefaultNewShop.md_ro_statuses, md_order_statuses: DefaultNewShop.md_order_statuses, md_responsibility_centers: DefaultNewShop.md_responsibility_centers, md_referral_sources: DefaultNewShop.md_referral_sources, md_messaging_presets: DefaultNewShop.md_messaging_presets, md_rbac: DefaultNewShop.md_rbac, md_classes: DefaultNewShop.md_classes, md_ins_cos: DefaultNewShop.md_ins_cos, md_categories: DefaultNewShop.md_categories, md_labor_rates: DefaultNewShop.md_labor_rates, md_payment_types: DefaultNewShop.md_payment_types, md_hour_split: DefaultNewShop.md_hour_split, md_ccc_rates: DefaultNewShop.md_ccc_rates, appt_alt_transport: DefaultNewShop.appt_alt_transport, md_jobline_presets: DefaultNewShop.md_jobline_presets, vendors: { data: trimmedBody.vendors.map((v) => ({ name: v.name, street1: v.street1, street2: v.street2, city: v.city, state: v.state, zip: v.zip, country: v.country, email: v.email, discount: v.discount, due_date: v.due_date, cost_center: v.cost_center, favorite: v.favorite, phone: v.phone, active: v.active, dmsid: v.dmsid })) } }; const newShopId = await insertBodyshop(shopInput); const userRecord = await createFirebaseUser(trimmedBody.userEmail, trimmedBody.userPassword); let resetLink = null; if (!trimmedBody.userPassword) { resetLink = await generateResetLink(trimmedBody.userEmail); } const createdUser = await insertUserAssociation(userRecord.uid, trimmedBody.userEmail, newShopId); return res.status(200).json({ shop: { id: newShopId, shopname: trimmedBody.shopname }, user: { id: createdUser.id, email: createdUser.email, resetLink: resetLink || undefined } }); } catch (err) { logger.log("admin-create-shop-user-error", "error", body.userEmail || "unknown", null, { message: err.message, detail: err.detail || err }); if (err.userRecord) { await deleteFirebaseUser(err.userRecord.uid).catch(() => { /* empty */ }); } if (err.newShopId) { await deleteVendorsByShop(err.newShopId).catch(() => { /* empty */ }); await deleteBodyshop(err.newShopId).catch(() => { /* empty */ }); } return res.status(err.status || 500).json({ error: err.message || "Internal server error" }); } }; module.exports = { partsManagementProvisioning, patchPartsManagementProvisioning };