feature/IO-3258-Shop-User-Vendor-Creation: Finish
This commit is contained in:
@@ -1035,6 +1035,7 @@
|
|||||||
- use_fippa
|
- use_fippa
|
||||||
- use_paint_scale_data
|
- use_paint_scale_data
|
||||||
- uselocalmediaserver
|
- uselocalmediaserver
|
||||||
|
- external_shop_id
|
||||||
- website
|
- website
|
||||||
- workingdays
|
- workingdays
|
||||||
- zip_post
|
- zip_post
|
||||||
@@ -1130,6 +1131,7 @@
|
|||||||
- use_fippa
|
- use_fippa
|
||||||
- use_paint_scale_data
|
- use_paint_scale_data
|
||||||
- uselocalmediaserver
|
- uselocalmediaserver
|
||||||
|
- external_shop_id
|
||||||
- website
|
- website
|
||||||
- workingdays
|
- workingdays
|
||||||
- zip_post
|
- zip_post
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Could not auto-generate a down migration.
|
||||||
|
-- Please write an appropriate down migration for the SQL below:
|
||||||
|
-- alter table "public"."bodyshops" add column "we_profile_id" text
|
||||||
|
-- null;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
alter table "public"."bodyshops" add column "we_profile_id" text
|
||||||
|
null;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
alter table "public"."bodyshops" rename column "parts_management_key" to "we_profile_id";
|
||||||
|
alter table "public"."bodyshops" drop constraint "bodyshops_we_profile_id_key";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
alter table "public"."bodyshops" add constraint "bodyshops_we_profile_id_key" unique ("we_profile_id");
|
||||||
|
alter table "public"."bodyshops" rename column "we_profile_id" to "parts_management_key";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
alter table "public"."bodyshops" rename column "external_shop_id" to "parts_management_key";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
alter table "public"."bodyshops" rename column "parts_management_key" to "external_shop_id";
|
||||||
1236
server/integrations/partsManagement/defaultNewShop.json
Normal file
1236
server/integrations/partsManagement/defaultNewShop.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
|||||||
|
const crypto = require("crypto");
|
||||||
|
const admin = require("firebase-admin");
|
||||||
|
const client = require("../../graphql-client/graphql-client").client;
|
||||||
|
const DefaultNewShop = require("./defaultNewShop.json");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that 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 email is not already registered in Firebase.
|
||||||
|
* @param email
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
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 provided email.
|
||||||
|
* @param email
|
||||||
|
* @returns {Promise<UserRecord>}
|
||||||
|
*/
|
||||||
|
const createFirebaseUser = async (email) => {
|
||||||
|
return admin.auth().createUser({ email });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a Firebase user by their UID.
|
||||||
|
* @param uid
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const deleteFirebaseUser = async (uid) => {
|
||||||
|
return admin.auth().deleteUser(uid);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a password reset link for the given email.
|
||||||
|
* @param email
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
const generateResetLink = async (email) => {
|
||||||
|
return admin.auth().generatePasswordResetLink(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that the external shop ID is unique in the database.
|
||||||
|
* @param externalId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const ensureExternalIdUnique = async (externalId) => {
|
||||||
|
const query = `
|
||||||
|
query CHECK_KEY($key: String!) {
|
||||||
|
bodyshops(where: { external_shop_id: { _eq: $key } }) {
|
||||||
|
external_shop_id
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const resp = await client.request(query, { key: externalId });
|
||||||
|
if (resp.bodyshops.length) {
|
||||||
|
throw { status: 400, message: `external_shop_id '${externalId}' is already in use.` };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a new bodyshop into the database.
|
||||||
|
* @param input
|
||||||
|
* @returns {Promise<*>}
|
||||||
|
*/
|
||||||
|
const insertBodyshop = async (input) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation CREATE_SHOP($bs: bodyshops_insert_input!) {
|
||||||
|
insert_bodyshops_one(object: $bs) { id }
|
||||||
|
}`;
|
||||||
|
const resp = await client.request(mutation, { bs: input });
|
||||||
|
return resp.insert_bodyshops_one.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all vendors associated with a specific shop ID.
|
||||||
|
* @param shopId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const deleteVendorsByShop = async (shopId) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation DELETE_VENDORS($shopId: uuid!) {
|
||||||
|
delete_vendors(where: { shopid: { _eq: $shopId } }) {
|
||||||
|
affected_rows
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
await client.request(mutation, { shopId });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a bodyshop by its ID.
|
||||||
|
* @param shopId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const deleteBodyshop = async (shopId) => {
|
||||||
|
const mutation = `
|
||||||
|
mutation DELETE_SHOP($id: uuid!) {
|
||||||
|
delete_bodyshops_by_pk(id: $id) { id }
|
||||||
|
}`;
|
||||||
|
await client.request(mutation, { 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 mutation = `
|
||||||
|
mutation CREATE_USER($u: users_insert_input!) {
|
||||||
|
insert_users_one(object: $u) {
|
||||||
|
id: authid
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const vars = {
|
||||||
|
u: {
|
||||||
|
email,
|
||||||
|
authid: uid,
|
||||||
|
validemail: true,
|
||||||
|
associations: {
|
||||||
|
data: [{ shopid: shopId, authlevel: 80, active: true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const resp = await client.request(mutation, vars);
|
||||||
|
return resp.insert_users_one;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the provisioning of a new parts management shop and user.
|
||||||
|
* @param req
|
||||||
|
* @param res
|
||||||
|
* @returns {Promise<*>}
|
||||||
|
*/
|
||||||
|
const partsManagementProvisioning = async (req, res) => {
|
||||||
|
const { logger } = req;
|
||||||
|
const p = { ...req.body, userEmail: req.body.userEmail?.toLowerCase() };
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate inputs
|
||||||
|
await ensureEmailNotRegistered(p.userEmail);
|
||||||
|
requireFields(p, [
|
||||||
|
"external_shop_id",
|
||||||
|
"shopname",
|
||||||
|
"address1",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"zip_post",
|
||||||
|
"country",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"userEmail"
|
||||||
|
]);
|
||||||
|
await ensureExternalIdUnique(p.external_shop_id);
|
||||||
|
|
||||||
|
logger.log("admin-create-shop-user", "debug", p.userEmail, null, {
|
||||||
|
request: req.body,
|
||||||
|
ioadmin: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create shop
|
||||||
|
const shopInput = {
|
||||||
|
shopname: p.shopname,
|
||||||
|
address1: p.address1,
|
||||||
|
address2: p.address2 || null,
|
||||||
|
city: p.city,
|
||||||
|
state: p.state,
|
||||||
|
zip_post: p.zip_post,
|
||||||
|
country: p.country,
|
||||||
|
email: p.email,
|
||||||
|
external_shop_id: p.external_shop_id,
|
||||||
|
timezone: p.timezone,
|
||||||
|
phone: p.phone,
|
||||||
|
logo_img_path: {
|
||||||
|
src: p.logoUrl,
|
||||||
|
width: "",
|
||||||
|
height: "",
|
||||||
|
headerMargin: DefaultNewShop.logo_img_path.headerMargin
|
||||||
|
},
|
||||||
|
md_ro_statuses: DefaultNewShop.md_ro_statuses,
|
||||||
|
vendors: {
|
||||||
|
data: p.vendors.map((v) => ({
|
||||||
|
name: v.name,
|
||||||
|
street1: v.street1 || null,
|
||||||
|
street2: v.street2 || null,
|
||||||
|
city: v.city || null,
|
||||||
|
state: v.state || null,
|
||||||
|
zip: v.zip || null,
|
||||||
|
country: v.country || null,
|
||||||
|
email: v.email || null,
|
||||||
|
discount: v.discount ?? 0,
|
||||||
|
due_date: v.due_date ?? null,
|
||||||
|
cost_center: v.cost_center || null,
|
||||||
|
favorite: v.favorite ?? [],
|
||||||
|
phone: v.phone || null,
|
||||||
|
active: v.active ?? true,
|
||||||
|
dmsid: v.dmsid || null
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const newShopId = await insertBodyshop(shopInput);
|
||||||
|
|
||||||
|
// Create user + association
|
||||||
|
const userRecord = await createFirebaseUser(p.userEmail);
|
||||||
|
const resetLink = await generateResetLink(p.userEmail);
|
||||||
|
const createdUser = await insertUserAssociation(userRecord.uid, p.userEmail, newShopId);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
shop: { id: newShopId, shopname: p.shopname },
|
||||||
|
user: {
|
||||||
|
id: createdUser.id,
|
||||||
|
email: createdUser.email,
|
||||||
|
resetLink
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.log("admin-create-shop-user-error", "error", p.userEmail, null, {
|
||||||
|
message: err.message,
|
||||||
|
detail: err.detail || err
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup on failure
|
||||||
|
if (err.userRecord) {
|
||||||
|
await deleteFirebaseUser(err.userRecord.uid).catch(() => {});
|
||||||
|
}
|
||||||
|
if (err.newShopId) {
|
||||||
|
await deleteVendorsByShop(err.newShopId).catch(() => {});
|
||||||
|
await deleteBodyshop(err.newShopId).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(err.status || 500).json({ error: err.message || "Internal server error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = partsManagementProvisioning;
|
||||||
160
server/integrations/partsManagement/swagger.yaml
Normal file
160
server/integrations/partsManagement/swagger.yaml
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Parts Management Provisioning API
|
||||||
|
description: API endpoint to provision a new shop and user in the Parts Management system.
|
||||||
|
version: 1.0.0
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/parts-management/provision:
|
||||||
|
post:
|
||||||
|
summary: Provision a new parts management shop and user
|
||||||
|
operationId: partsManagementProvisioning
|
||||||
|
tags:
|
||||||
|
- Parts Management
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- external_shop_id
|
||||||
|
- shopname
|
||||||
|
- address1
|
||||||
|
- city
|
||||||
|
- state
|
||||||
|
- zip_post
|
||||||
|
- country
|
||||||
|
- email
|
||||||
|
- phone
|
||||||
|
- userEmail
|
||||||
|
properties:
|
||||||
|
external_shop_id:
|
||||||
|
type: string
|
||||||
|
description: External shop ID (must be unique)
|
||||||
|
shopname:
|
||||||
|
type: string
|
||||||
|
address1:
|
||||||
|
type: string
|
||||||
|
address2:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
city:
|
||||||
|
type: string
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
zip_post:
|
||||||
|
type: string
|
||||||
|
country:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
phone:
|
||||||
|
type: string
|
||||||
|
userEmail:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
logoUrl:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
nullable: true
|
||||||
|
timezone:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
vendors:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
street1:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
street2:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
city:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
zip:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
country:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
nullable: true
|
||||||
|
discount:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
due_date:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
nullable: true
|
||||||
|
cost_center:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
favorite:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
phone:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
active:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
|
dmsid:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Shop and user successfully created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
shop:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
shopname:
|
||||||
|
type: string
|
||||||
|
user:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
resetLink:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
'400':
|
||||||
|
description: Bad request (missing or invalid fields)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
'500':
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
23
server/middleware/partsManagementIntegrationMiddleware.js
Normal file
23
server/middleware/partsManagementIntegrationMiddleware.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Middleware to check if the request is authorized for Parts Management Integration.
|
||||||
|
* @param req
|
||||||
|
* @param res
|
||||||
|
* @param next
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
const partsManagementIntegrationMiddleware = (req, res, next) => {
|
||||||
|
const secret = process.env.PARTS_MANAGEMENT_INTEGRATION_SECRET;
|
||||||
|
if (typeof secret !== "string" || secret.length === 0) {
|
||||||
|
return res.status(500).send("Server misconfiguration");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerValue = req.headers["parts-management-integration-secret"];
|
||||||
|
if (typeof headerValue !== "string" || headerValue.trim() !== secret) {
|
||||||
|
return res.status(401).send("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
req.isPartsManagementIntegrationAuthorized = true;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = partsManagementIntegrationMiddleware;
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* VSSTA Integration Middleware
|
* VSSTA Integration Middleware
|
||||||
* @param req
|
* Fails closed if the env var is missing or empty, and strictly compares header.
|
||||||
* @param res
|
|
||||||
* @param next
|
|
||||||
* @returns {*}
|
|
||||||
*/
|
*/
|
||||||
const vsstaIntegrationMiddleware = (req, res, next) => {
|
const vsstaIntegrationMiddleware = (req, res, next) => {
|
||||||
if (req?.headers?.["vssta-integration-secret"] !== process.env?.VSSTA_INTEGRATION_SECRET) {
|
const secret = process.env.VSSTA_INTEGRATION_SECRET;
|
||||||
|
if (typeof secret !== "string" || secret.length === 0) {
|
||||||
|
return res.status(500).send("Server misconfiguration");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerValue = req.headers["vssta-integration-secret"];
|
||||||
|
if (typeof headerValue !== "string" || headerValue.trim() !== secret) {
|
||||||
return res.status(401).send("Unauthorized");
|
return res.status(401).send("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
req.isIntegrationAuthorized = true;
|
req.isVsstaIntegrationAuthorized = true;
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const vsstaIntegration = require("../integrations/VSSTA/vsstaIntegrationRoute");
|
|
||||||
const vsstaMiddleware = require("../middleware/vsstaIntegrationMiddleware");
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post("/vssta", vsstaMiddleware, vsstaIntegration);
|
// Pull secrets from env
|
||||||
|
const { VSSTA_INTEGRATION_SECRET, PARTS_MANAGEMENT_INTEGRATION_SECRET } = process.env;
|
||||||
|
|
||||||
|
// Only load VSSTA routes if the secret is set
|
||||||
|
if (typeof VSSTA_INTEGRATION_SECRET === "string" && VSSTA_INTEGRATION_SECRET.length > 0) {
|
||||||
|
const vsstaIntegration = require("../integrations/VSSTA/vsstaIntegrationRoute");
|
||||||
|
const vsstaMiddleware = require("../middleware/vsstaIntegrationMiddleware");
|
||||||
|
|
||||||
|
router.post("/vssta", vsstaMiddleware, vsstaIntegration);
|
||||||
|
} else {
|
||||||
|
console.warn("VSSTA_INTEGRATION_SECRET is not set — skipping /vssta integration route");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only load Parts Management routes if that secret is set
|
||||||
|
if (typeof PARTS_MANAGEMENT_INTEGRATION_SECRET === "string" && PARTS_MANAGEMENT_INTEGRATION_SECRET.length > 0) {
|
||||||
|
const partsManagementProvisioning = require("../integrations/partsManagement/partsManagementProvisioning");
|
||||||
|
const partsManagementIntegrationMiddleware = require("../middleware/partsManagementIntegrationMiddleware");
|
||||||
|
|
||||||
|
router.post("/parts-management/provision", partsManagementIntegrationMiddleware, partsManagementProvisioning);
|
||||||
|
} else {
|
||||||
|
console.warn("PARTS_MANAGEMENT_INTEGRATION_SECRET is not set — skipping /parts-management/provision route");
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
Reference in New Issue
Block a user