diff --git a/.gitignore b/.gitignore index 7e70a93..fac5dcb 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,9 @@ package-lock.json yarn.lock medusa-db.sql build -.cache \ No newline at end of file + +*.json +.cache + +.env.* +!.env.template \ No newline at end of file diff --git a/dump.rdb b/dump.rdb index 4fdc889..5c4a869 100644 Binary files a/dump.rdb and b/dump.rdb differ diff --git a/medusa-config.js b/medusa-config.js index e25256d..db6cfe5 100644 --- a/medusa-config.js +++ b/medusa-config.js @@ -1,9 +1,10 @@ const dotenv = require("dotenv"); +const { existsSync } = require("fs"); let ENV_FILE_NAME = ".env"; switch (process.env.NODE_ENV) { case "production": - ENV_FILE_NAME = ".env.production"; + ENV_FILE_NAME = existsSync('./.env.production') ? '.env.production' : '.env' break; case "staging": ENV_FILE_NAME = ".env.staging"; @@ -12,6 +13,8 @@ switch (process.env.NODE_ENV) { ENV_FILE_NAME = ".env.test"; break; case "development": + ENV_FILE_NAME = existsSync('./.env.development') ? '.env.development' : '.env' + break; default: ENV_FILE_NAME = ".env"; break; @@ -32,6 +35,7 @@ const DATABASE_TYPE = process.env.DATABASE_TYPE || "sqlite"; const DATABASE_URL = process.env.DATABASE_URL || "postgres://localhost/medusa-store"; + const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379"; const plugins = [ @@ -82,9 +86,6 @@ const plugins = [ if (!prices || !id || Object.values(prices).length < 3) { return null; } - - console.log("Updated:", id, prices); - return { id, prices, @@ -164,7 +165,7 @@ const projectConfig = { if (DATABASE_URL && DATABASE_TYPE === "postgres") { projectConfig.database_url = DATABASE_URL; delete projectConfig["database_database"]; -} +} /** @type {import('@medusajs/medusa').ConfigModule} */ module.exports = { diff --git a/package.json b/package.json index 2f3e71d..82b3dbf 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "seed": "medusa seed -f ./data/seed.json", "start": "npm run build && medusa migrations run && medusa develop -p=8080", "dev": "npm run build && medusa develop", + "dev:prod": "cross-env NODE_ENV=production npm run build && medusa start", "build:admin": "medusa-admin build", "migration:run": "npm run build && npx typeorm migration:run -d datasource.js", "migration:generate": "npx typeorm migration:generate -d datasource.js" @@ -28,17 +29,26 @@ "@aws-sdk/client-s3": "^3.425.0", "@aws-sdk/s3-request-presigner": "^3.425.0", "@babel/preset-typescript": "^7.21.5", + "@google-cloud/local-auth": "^2.1.0", "@medusajs/admin": "^7.1.3", "@medusajs/cache-inmemory": "^1.8.7", "@medusajs/cache-redis": "^1.8.7", "@medusajs/event-bus-local": "^1.9.4", "@medusajs/event-bus-redis": "^1.8.7", - "@medusajs/medusa": "^1.17.0", + "@medusajs/medusa": "1.16.1", "@medusajs/medusa-cli": "^1.3.15", + "@types/node-fetch": "^2.6.6", + "async": "^3.2.4", + "axios": "^1.5.1", "babel-preset-medusa-package": "^1.1.19", "body-parser": "^1.20.2", "cors": "^2.8.5", "express": "^4.18.2", + "google-auth-library": "^9.1.0", + "google-spreadsheet": "^4.1.0", + "googleapis": "^105.0.0", + "ky": "^1.0.1", + "ky-universal": "^0.12.0", "medusa-file-s3": "^1.2.0", "medusa-fulfillment-manual": "^1.1.37", "medusa-interfaces": "^1.3.7", @@ -48,6 +58,8 @@ "medusa-plugin-segment": "^1.3.5", "medusa-plugin-sendgrid": "^1.3.9", "meilisearch": "^0.32.5", + "nanoid": "^5.0.1", + "node-fetch": "2", "openai": "^3.2.1", "typeorm": "^0.3.16", "ulid": "^2.3.0" @@ -55,9 +67,11 @@ "devDependencies": { "@babel/cli": "^7.21.5", "@babel/core": "^7.22.1", + "@types/async": "^3.2.21", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/multer": "^1.4.8", + "@types/nanoid": "^3.0.0", "@types/node": "^20.2.5", "babel-preset-medusa-package": "^1.1.19", "cross-env": "^7.0.3", @@ -98,4 +112,4 @@ "engines": { "node": ">=18.0.0" } -} +} \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts index 2d72731..071879a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -9,6 +9,10 @@ import { listDeletedCustomers } from "./routes/admin/list-deleted"; import { restoreCustomer } from "./routes/admin/restore-customer"; import { uploadFile } from "./routes/global/uploads"; import { S3Client } from "@aws-sdk/client-s3"; +import { SheetsRouter } from "./routes/admin/sheets"; +import { SheetsSyncCategoriesRouter } from "./routes/admin/sheets/sync-categories"; +import { SheetsSyncLocationsRouter } from "./routes/admin/sheets/sync-locations"; +import { SheetsSyncProductsRouter } from "./routes/admin/sheets/sync-products"; export default (rootDirectory: string): Router | Router[] => { const { configModule: { projectConfig } } = getConfigFile(rootDirectory, "medusa-config") @@ -25,7 +29,16 @@ export default (rootDirectory: string): Router | Router[] => { router.use(cors(storefrontCorsConfig)) // endpoints - const endpointHandlers = [deleteCustomer, listDeletedCustomers, restoreCustomer, uploadFile] + const endpointHandlers = [ + deleteCustomer, + listDeletedCustomers, + restoreCustomer, + uploadFile, + SheetsRouter, + SheetsSyncCategoriesRouter, + SheetsSyncLocationsRouter, + SheetsSyncProductsRouter + ] endpointHandlers.forEach(endpointHandle => endpointHandle(router, { s3Client })) return router diff --git a/src/api/routes/admin/sheets/helpers/mapSheetProduct.ts b/src/api/routes/admin/sheets/helpers/mapSheetProduct.ts new file mode 100644 index 0000000..52a7890 --- /dev/null +++ b/src/api/routes/admin/sheets/helpers/mapSheetProduct.ts @@ -0,0 +1,38 @@ +import { Product, ProductStatus } from "@medusajs/medusa"; +import { CreateProductInput } from "@medusajs/medusa/dist/types/product"; +import { ulid } from "ulid"; + + +type ExtraProps = { + stock: number, + categories: { id: string }[], + rowNumber: number; +} + +export type CreateProduct = Omit, 'variants' | 'categories'> & ExtraProps + +export type CreateProductResponse = CreateProductInput & { id: string, rowNumber: number } + +export const mapSheetProduct = (product: CreateProduct): CreateProductResponse => { + return ({ + id: product.id, + rowNumber: product.rowNumber, + title: product.title, + handle: `${product.title.toLowerCase().replace(/-/g, '').replace(/ /g, '-')}_${ulid()}`, + description: product.description, + status: !product.stock ? ProductStatus.DRAFT : product.status, + categories: product.categories, + variants: [ + { + title: 'one size', + inventory_quantity: product.stock || 0, + prices: [ + { + amount: 10000, + currency_code: 'usd' + } + ] + } + ], + }) +} \ No newline at end of file diff --git a/src/api/routes/admin/sheets/index.ts b/src/api/routes/admin/sheets/index.ts new file mode 100644 index 0000000..f10b4a1 --- /dev/null +++ b/src/api/routes/admin/sheets/index.ts @@ -0,0 +1,65 @@ +import { ProductStatus, authenticate } from "@medusajs/medusa" +import express, { Router } from "express"; +import CategoryService from "../../../../services/category"; +import GoogleSheetAPIService, { ProductData } from "../../../../services/google-sheet-api"; +import ProductService from "../../../../services/product"; +import { mapSheetProduct } from "./helpers/mapSheetProduct"; + +type CategoriesID = { id: string }[]; + +type SheetProductWithCategories = ProductData & { categories: CategoriesID }; + +export const SheetsRouter = (router: Router) => { + router.use('/admin/sheets', express.json(), authenticate()); + + router.get('/admin/sheets', async (req, res) => { + const googleSheetService = req.scope.resolve('googleSheetApiService') as GoogleSheetAPIService; + const categoryService = req.scope.resolve('categoryService') as CategoryService; + const productService = req.scope.resolve('productService') as ProductService + + googleSheetService.sheetId = (req.query.sheetId as string) || '1TaiFMTqYGirhLrjUkEfCGbV3hCrX9po_tduFw_sETUg'; + + try { + const sheetData = await googleSheetService.getProductDataBySheetId(); + + const promises = sheetData.map(async (entry) => { + const location = await categoryService.getCategoryByName(entry.Location); + const category = await categoryService.getCategoryByName(entry.Category); + + const categories: { id: string }[] = [] + + if (location) { + categories.push({ id: location.id }) + } + if (category) { + categories.push({ id: category.id }) + } + + return { ...entry, categories } + }); + + let results = (await Promise.allSettled(promises)).filter(r => r.status === 'fulfilled').map((r: any) => r.value) + + results = results.map((product: SheetProductWithCategories) => { + return mapSheetProduct({ + id: product['Product ID'], + title: product['Product title'], + description: product.Description, + stock: product.Stocks, + categories: product.categories, + rowNumber: product.rowNumber, + status: product.Draft ? ProductStatus.DRAFT : ProductStatus.PUBLISHED + }) + }); + + const bulkAddResult = await productService.addBulkProducts(results) + const savedProductIdAndRowNumber = bulkAddResult.saved.map((p) => ({ id: p.id, rowNumber: p.rowNumber })) + + const affectedCellsCount = await googleSheetService.updateProductId(savedProductIdAndRowNumber) + + return res.json({ status: 'ok', affectedCellsCount }) + } catch (error) { + return res.status(500).json({ status: 500, message: 'An error occurred!', error: error instanceof Error ? error.message : error }) + } + }) +} \ No newline at end of file diff --git a/src/api/routes/admin/sheets/sync-categories.ts b/src/api/routes/admin/sheets/sync-categories.ts new file mode 100644 index 0000000..471cf7c --- /dev/null +++ b/src/api/routes/admin/sheets/sync-categories.ts @@ -0,0 +1,29 @@ +import { authenticate } from "@medusajs/medusa" +import express, { Router } from "express"; +import CategoryService from "../../../../services/category"; +import GoogleSheetAPIService from "../../../../services/google-sheet-api"; + + +export const SheetsSyncCategoriesRouter = (router: Router) => { + router.use('/admin/sheets/sync-categories', express.json(), authenticate()); + + router.get('/admin/sheets/sync-categories', async (req, res) => { + const googleSheetService = req.scope.resolve('googleSheetApiService') as GoogleSheetAPIService; + const categoryService = req.scope.resolve('categoryService') as CategoryService; + + googleSheetService.sheetId = (req.query.sheetId as string) || '1TaiFMTqYGirhLrjUkEfCGbV3hCrX9po_tduFw_sETUg' + + try { + + const categories = await categoryService.retrieveAllCategoriesName() + + if (categories.length) { + await googleSheetService.syncCategories(categories) + } + + return res.json({ status: categories.length ? 'ok' : 'failed, no categories available!' }) + } catch (error) { + return res.status(500).json({ status: 500, message: 'An error occurred!', error: error instanceof Error ? error.message : error }) + } + }) +} \ No newline at end of file diff --git a/src/api/routes/admin/sheets/sync-locations.ts b/src/api/routes/admin/sheets/sync-locations.ts new file mode 100644 index 0000000..761b7e7 --- /dev/null +++ b/src/api/routes/admin/sheets/sync-locations.ts @@ -0,0 +1,30 @@ +import { authenticate } from "@medusajs/medusa"; +import express, { Router } from "express"; +import CategoryService from "../../../../services/category"; +import GoogleSheetAPIService from "../../../../services/google-sheet-api"; + + +export const SheetsSyncLocationsRouter = (router: Router) => { + router.use('/admin/sheets/sync-locations', express.json(), authenticate()); + + router.get('/admin/sheets/sync-locations', async (req, res) => { + const googleSheetService = req.scope.resolve('googleSheetApiService') as GoogleSheetAPIService; + const categoryService = req.scope.resolve('categoryService') as CategoryService; + + googleSheetService.sheetId = (req.query.sheetId as string) || '1TaiFMTqYGirhLrjUkEfCGbV3hCrX9po_tduFw_sETUg' + + try { + + const lastLocations = await categoryService.retrieveAllLastLocationsName() + + + if (lastLocations.length) { + await googleSheetService.syncLocations(lastLocations) + } + + return res.json({ status: lastLocations.length ? 'ok' : 'failed, no locations available!' }) + } catch (error) { + return res.status(500).json({ status: 500, message: 'An error occurred!', error: error instanceof Error ? error.message : error }) + } + }) +} \ No newline at end of file diff --git a/src/api/routes/admin/sheets/sync-products.ts b/src/api/routes/admin/sheets/sync-products.ts new file mode 100644 index 0000000..f12f45f --- /dev/null +++ b/src/api/routes/admin/sheets/sync-products.ts @@ -0,0 +1,26 @@ +import { authenticate } from "@medusajs/medusa" +import express, { Router } from "express"; +import GoogleSheetAPIService from "../../../../services/google-sheet-api"; +import ProductService from "../../../../services/product"; + + +export const SheetsSyncProductsRouter = (router: Router) => { + router.use('/admin/sheets/sync-products', express.json(), authenticate()); + + router.get('/admin/sheets/sync-products', async (req, res) => { + const googleSheetService = req.scope.resolve('googleSheetApiService') as GoogleSheetAPIService; + const productService = req.scope.resolve('productService') as ProductService; + + googleSheetService.sheetId = (req.query.sheetId as string) || '1TaiFMTqYGirhLrjUkEfCGbV3hCrX9po_tduFw_sETUg' + + try { + const products = await productService.list({}, { relations: ['categories'] }) + + googleSheetService.syncProducts(products) + + return res.json({ status: products.length ? 'ok' : 'failed, no categories available!' }) + } catch (error) { + return res.status(500).json({ status: 500, message: 'An error occurred!', error: error instanceof Error ? error.message : error }) + } + }) +} \ No newline at end of file diff --git a/src/api/routes/global/uploads.ts b/src/api/routes/global/uploads.ts index 7f1b965..c5dd2b8 100644 --- a/src/api/routes/global/uploads.ts +++ b/src/api/routes/global/uploads.ts @@ -3,6 +3,7 @@ import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { authenticate, authenticateCustomer } from "@medusajs/medusa"; import express, { Router } from "express"; import { ulid } from "ulid"; +import { getFileExtensionFromContentType } from "../../../utils/getFileExtensionFromContentType"; export const uploadFile = (router: Router, { s3Client }: { s3Client: S3Client }) => { router.use('/uploads/getPreSignedUrl', express.json()) @@ -15,14 +16,14 @@ export const uploadFile = (router: Router, { s3Client }: { s3Client: S3Client }) return res.status(403).send() } } - const ext = req.query.ext as string - if (!ext) return res.status(422).send('Missing file extension!'); + const contentType = req.query.type as string + if (!getFileExtensionFromContentType(contentType)) return res.status(422).send('Missing or invalid file extension!'); const id = req.user.customer_id || req.user.userId; - const key = `${id}/${ulid()}.${ext}` - const command = new PutObjectCommand({ Bucket: process.env.AWS_BUCKET, Key: key }); + const key = `${id}/${ulid()}${getFileExtensionFromContentType(contentType)}` + const command = new PutObjectCommand({ Bucket: process.env.AWS_BUCKET, Key: key, ContentType: contentType }); const url = await getSignedUrl(s3Client, command, { expiresIn: 300 }); // 5 minutes diff --git a/src/services/category.ts b/src/services/category.ts new file mode 100644 index 0000000..484a727 --- /dev/null +++ b/src/services/category.ts @@ -0,0 +1,59 @@ +import { ProductCategory, ProductCategoryService } from "@medusajs/medusa"; +import { buildLocationTree, getLastLocationPath, getLocationPath, getLocationPathWithLastID } from "../utils/convertIntoNestedLocations"; + +class CategoryService extends ProductCategoryService { + constructor(container) { + super(container) + } + async retrieveAllCategoriesName() { + const retrieveAllCategories = ` + SELECT name FROM product_category WHERE handle NOT LIKE 'loc:%' + ` + + const result = await this.activeManager_.query(retrieveAllCategories) as { name: string }[] + return result?.filter(c => c.name !== 'Location master').map(category => category.name) + } + + async retrieveAllLastLocationsName() { + const retrieveAllLocations = ` + SELECT name, id, parent_category_id FROM "product_category" WHERE handle LIKE 'loc:%' + ` + + const result = await this.activeManager_.query(retrieveAllLocations) as { name: string, id: string, parent_category_id: string }[] + + // Convert the location tree to slash-separated strings + + const nestedLocations = buildLocationTree(result); + const locationStrings: string[] = getLocationPath(nestedLocations) + return locationStrings + } + + + async getCategoryByName(name: string) { + const queryByName = ` + SELECT * FROM product_category WHERE name ILIKE $1 + `; + const result = await this.activeManager_.query(queryByName, [name]); + return result[0] as ProductCategory + } + + + async getLocationWithParentNames(id: string) { + const locations: ProductCategory[] = [] + + const getLocation = async (locationId: string) => { + const location = await this.retrieve(locationId); + + if (location.parent_category_id) { + locations.unshift(location) + await getLocation(location.parent_category_id) + } + } + + await getLocation(id) + + return locations.map(l => l.name).join(' / ') + } + +} +export default CategoryService \ No newline at end of file diff --git a/src/services/customer.ts b/src/services/customer.ts index a389c94..997f84d 100644 --- a/src/services/customer.ts +++ b/src/services/customer.ts @@ -1,11 +1,10 @@ -import { CustomerService as BaseCustomerService, Customer } from '@medusajs/medusa' +import { CustomerService as BaseCustomerService, Customer, FindConfig } from '@medusajs/medusa' export default class CustomerService extends BaseCustomerService { constructor(container) { super(container) } - async retrieveDeletedCustomer() { const deletedCustomerQuery = ` SELECT id, email, first_name, last_name, billing_address_id, phone, has_account, metadata, created_at, updated_at, deleted_at FROM customer WHERE deleted_at IS NOT NULL diff --git a/src/services/google-sheet-api.ts b/src/services/google-sheet-api.ts new file mode 100644 index 0000000..caf1b89 --- /dev/null +++ b/src/services/google-sheet-api.ts @@ -0,0 +1,198 @@ +import { Product, ProductStatus, TransactionBaseService } from "@medusajs/medusa"; +import { google, sheets_v4 } from "googleapis"; +import { buildLocationTree, getLocationPathWithLastID } from "utils/convertIntoNestedLocations"; +import CategoryService from "./category"; + +export type ProductData = { + 'Product title': string; + 'Product ID': string; + Description: string; + Location: string; + Category: string; + Draft: boolean; + Stocks: number; + Image_1: string; + Image_2: string; + Image_3: string; + Thumbnail: string; + rowNumber: number; +} + +class GoogleSheetAPIService extends TransactionBaseService { + // Initialize Google Sheet API service + sheets: sheets_v4.Sheets; + sheetId: string; + categoryService: CategoryService; + + constructor(container) { + super(container); + // Initialize Google Authentication and create the Google Sheets service + const auth = new google.auth.GoogleAuth({ + keyFile: process.env.KEY_FILE_PATH, // Specify the path to your service account key file + scopes: ["https://www.googleapis.com/auth/spreadsheets"], // Define required scopes + }); + this.sheets = google.sheets({ version: 'v4', auth }); + this.categoryService = container.categoryService + } + + // Retrieve data from a Google Sheet by its ID, specifying sheet name and cell range + // Original function for retrieving data as an object of arrays + async getProductDataBySheetId(sheetName = 'Sheet1', cellSelection = 'A1:Z') { + // Fetch data from the specified Google Sheet + const response = await this.sheets.spreadsheets.values.get({ + spreadsheetId: this.sheetId, + range: `${sheetName}!${cellSelection}`, + }); + + // Extract headers from the response data + const headers = response.data.values?.[0]; + + // Create an array to store the result objects (each object represents a single item) + const csvDataArray = []; + + // Filter and process the data to create the array of objects + const filteredData = response.data.values?.filter(row => row.slice(1).every((cell) => cell !== '' && cell !== null)); + + filteredData.slice(1).forEach((row, index) => { + const itemData = { + rowNumber: index + 2 // 1 for index, 2nd for header. so adding 2. + }; // Create an object to represent an item + + row.forEach((cell, columnIndex) => { + const header = headers[columnIndex]; + + // Check for missing header + if (!header) throw new Error('Header not found but data is present. Failed to map data!'); + + // Process cell values and add them to the itemData object + switch (cell) { + case 'TRUE': + itemData[header] = true; + break; + case 'FALSE': + itemData[header] = false; + break; + default: + try { + const columnAsNumber = /^-?\d+$/.test(cell) && parseInt(cell); + if (columnAsNumber && !Number.isNaN(columnAsNumber)) { + itemData[header] = columnAsNumber; + } else { + itemData[header] = cell; + } + } catch (error) { + // Handle any potential errors during data processing + itemData[header] = cell; + } + } + }); + csvDataArray.push(itemData) + }); + + return csvDataArray as ProductData[] + } + + async updateProductId(payload: { id: string; rowNumber: number }[]) { + + const valueUpdates = payload.map(p => ({ range: `A${p.rowNumber}`, values: [[p.id]] })); + + const response = await this.sheets.spreadsheets.values.batchUpdate({ + spreadsheetId: this.sheetId, + requestBody: { + valueInputOption: 'RAW', + data: valueUpdates + } + }) + + return response.data.totalUpdatedCells + } + + async syncProducts(products: Product[]) { + + Promise.all(products.map(async (product) => { + const location = product.categories?.filter(c => c.handle.startsWith('loc:'))[0]; + const category = product.categories?.filter(c => !c.handle.startsWith('loc:'))[0]; + + + return [ + // product id + product.id, + + // product title + product.title, + + // description + product.description, + + // location + !location ? '' : await this.categoryService.getLocationWithParentNames(location.id), + + // category + !category ? '' : category.name, + + // Draft + product.status === ProductStatus.DRAFT, + + // Stocks + product.variants?.[0].inventory_quantity || '' + ] + })).then(async (sheetProducts) => { + const response = await this.sheets.spreadsheets.values.get({ + spreadsheetId: this.sheetId, + range: `Sheet1!A2:A`, + }) + + const cellLength = response.data.values?.length || 0; + + const targetedLength = Math.max(sheetProducts.length, cellLength) + + const values = Array(targetedLength).fill(null).map((_, i) => sheetProducts[i] || ['', '', '', '', '', '', '']); + + return this.sheets.spreadsheets.values.update({ + spreadsheetId: this.sheetId, + range: `Sheet1!A2:G`, + valueInputOption: 'RAW', + requestBody: { range: `Sheet1!A2:G`, values } + }) + }); + + } + + async syncCategories(categories: string[] = []) { + const response = await this.sheets.spreadsheets.values.get({ + spreadsheetId: this.sheetId, + range: `Sheet2!A2:A`, + }) + + const cellLength = response.data.values?.length; + + const targetedLength = Math.max(categories.length, cellLength || 0) + const values = Array(targetedLength).fill(null).map((_, i) => categories[i] || '').sort((a, z) => !a.length ? 0 : a > z ? 1 : -1).map(c => [c]); + return this.sheets.spreadsheets.values.update({ + spreadsheetId: this.sheetId, + range: `Sheet2!A2:A`, + valueInputOption: 'RAW', + requestBody: { range: `Sheet2!A2:A`, values } + }) + } + + async syncLocations(locations: string[] = []) { + const response = await this.sheets.spreadsheets.values.get({ + spreadsheetId: this.sheetId, + range: `Sheet2!B2:B`, + }) + + const cellLength = response.data.values?.length || 0; + + const targetedLength = Math.max(locations.length, cellLength) + const values = Array(targetedLength).fill(null).map((_, i) => locations[i] || '').sort((a, z) => !a.length ? 0 : a > z ? 1 : -1).map(c => [c]); + return this.sheets.spreadsheets.values.update({ + spreadsheetId: this.sheetId, + range: `Sheet2!B2:B`, + valueInputOption: 'RAW', + requestBody: { range: `Sheet2!B2:B`, values } + }) + } +} + +export default GoogleSheetAPIService; diff --git a/src/services/product.ts b/src/services/product.ts new file mode 100644 index 0000000..e565e2d --- /dev/null +++ b/src/services/product.ts @@ -0,0 +1,120 @@ +import { ProductService as BaseProductService, Product, ProductVariantService } from "@medusajs/medusa"; +import { CreateProductInput, FindProductConfig, UpdateProductInput } from "@medusajs/medusa/dist/types/product"; +import axios from 'axios'; + +class ProductService extends BaseProductService { + productVariantService: ProductVariantService; + base_url?: string | null; + token?: string; + credentials: { + email?: string | null; + password?: string | null; + } + + constructor(container) { + super(container); + this.productVariantService = container.productVariantService as ProductVariantService + this.base_url = process.env.BASE_URL; + this.credentials = { + email: process.env.MEDUSA_BACKEND_SERVICE_EMAIL, + password: process.env.MEDUSA_BACKEND_SERVICE_PASSWORD + } + } + + private async loginAdmin() { + + if (!this.base_url) { + throw new Error('Backend service host url is missing!. Please add them or contact administrator!') + } + + if (!this.credentials.email || !this.credentials.password) { + throw new Error('Backend service credentials are missing!. Please add them or contact administrator!') + } + + try { + // login system user to admin + const result = await axios.post(`${process.env.BASE_URL}/admin/auth/token`, { + 'email': this.credentials.email, + 'password': this.credentials.password + }); + + this.token = result.data?.access_token; + + } catch (error) { + console.log(error); + throw new Error('Failed to login as backend service!. ensure credentials are correct and stable network connection!') + } + } + + private async createProductWithFetch(product: CreateProductInput) { + const response = await axios.post<{ product: Product }>(`${this.base_url}/admin/products`, product, { + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + return response.data; + } + + private async updateProductWithFetch(id: string, product: UpdateProductInput) { + const response = await axios.post<{ product: Product }>(`${this.base_url}/admin/products/${id}`, product, { + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + return response.data; + } + + async addBulkProducts(products: ((CreateProductInput | UpdateProductInput) & { id: string; rowNumber: number; })[]) { + await this.loginAdmin(); + + const promises = products.map((product, i) => { + return new Promise>(async (resolve, reject) => { + if (product.id) { + let productExists; + try { + await this.retrieve(product.id) + productExists = true + } catch (error) { + productExists = false; + } + + if (productExists) { + const { id, rowNumber, ...updateProduct } = product as UpdateProductInput & { id: string; rowNumber: number; }; + const { product: updatedProduct } = await this.updateProductWithFetch(id, updateProduct) + resolve({ ...updatedProduct, rowNumber }); + } else { + + const { id, rowNumber, ...createProduct } = product as CreateProductInput & { id: string; rowNumber: number; } + + const { product: createdProduct } = await this.createProductWithFetch(createProduct); + + resolve({ ...createdProduct, rowNumber }) + } + } else { + const { id, rowNumber, ...createProduct } = product as CreateProductInput & { id: string, rowNumber: number; } + + const { product: createdProduct } = await this.createProductWithFetch(createProduct) + + resolve({ ...createdProduct, rowNumber }) + } + }) + }); + + try { + const saved: Partial[] = [] + const failed: Error[] = []; + + (await Promise.allSettled(promises)).forEach((promiseProduct) => promiseProduct.status === 'fulfilled' ? saved.push(promiseProduct.value) : failed.push(promiseProduct.reason)) + + return { + saved, + failed + } + + } catch (error) { + console.log(error); + } + } +} + +export default ProductService \ No newline at end of file diff --git a/src/subscribers/productdescription.ts b/src/subscribers/productdescription.ts index ac2bd28..6badf45 100644 --- a/src/subscribers/productdescription.ts +++ b/src/subscribers/productdescription.ts @@ -12,6 +12,19 @@ class ProductDescriptionSubscriber { this.productService = productService; eventBusService.subscribe(ProductService.Events.CREATED, this.handleDescription); eventBusService.subscribe(ProductService.Events.CREATED, this.handleMetaDescription); + + + // update all existing products + + this.productService.list({}).then((products) => { + products.map((p) => { + + // Do Your Task Here. + + // console.log(`Generating meta description of ${p.title}`); + this.handleMetaDescription({ id: p.id }) + }); + }) } handleMetaDescription = async ({ id }: { id: string }) => { @@ -22,18 +35,21 @@ class ProductDescriptionSubscriber { try { - prompt = await this.prepareDescription(metaDescription(product.title, [product.subtitle, product.material])); + prompt = await this.prepareDescription(metaDescription(product.title, [product.subtitle, product.material]), { maxTokens: 160 }); + } catch (error) { prompt = `${product.title}: ${[product.subtitle, product.material].join(', ')}` } + if (product.metadata) { product.metadata.meta_description = prompt; } else { product.metadata = { meta_description: prompt }; } + try { - await this.productService.update(product.id, product as any); + // await this.productService.update(product.id, product as any); } catch (error) { } } @@ -64,32 +80,21 @@ class ProductDescriptionSubscriber { }; prepareDescription = async ( - prompt, - retries = 0, - model = "text-davinci-003", - temperature = 0.7, - maxTokens = 256, - topP = 1, - frequencyPenalty = 0, - presencePenalty = 0, + prompt: string, + { + retries = 0, + model = "gpt-3.5-turbo", + maxTokens = 256 } = {} ) => { const openai = new OpenAIApi(configuration); try { - const response = await openai.createCompletion({ - model, - prompt, - temperature, - max_tokens: maxTokens, - top_p: topP, - frequency_penalty: frequencyPenalty, - presence_penalty: presencePenalty, - }); + const response = await openai.createChatCompletion({ messages: [{ role: 'system', content: prompt }], model, max_tokens: maxTokens }); - return response.data.choices[0].text.trim(); + return response.data.choices[0].message.content } catch (error) { if (!(retries >= 3)) { - this.prepareDescription(prompt, retries + 1); + this.prepareDescription(prompt, { retries: retries + 1 }); } else { throw new Error('No description provided!'); } diff --git a/src/utils/convertIntoNestedLocations.ts b/src/utils/convertIntoNestedLocations.ts new file mode 100644 index 0000000..a9c85d0 --- /dev/null +++ b/src/utils/convertIntoNestedLocations.ts @@ -0,0 +1,81 @@ +interface Location { + name: string; + id: string; + parent_category_id: string; + children?: Location[]; +} + +export function buildLocationTree(locations: Location[]): Location[] { + const locationMap = new Map(); + const rootLocations: Location[] = []; + + // Create a map of locations using their IDs as keys + locations.forEach((location) => { + location.children = []; + locationMap.set(location.id, location); + }); + + // Build the tree structure + locations.forEach((location) => { + const parentLocation = locationMap.get(location.parent_category_id); + if (parentLocation) { + parentLocation.children.push(location); + } else { + // If the parent doesn't exist, it's a root location + rootLocations.push(location); + } + }); + + return rootLocations; +} + +export function getLocationPath(locations: Location[]) { + return locations.map((l) => { + const path = []; + function getPath(loc: Location) { + path.push(loc.name) + if (loc.children) { + loc.children.map(getPath) + } + } + getPath(l) + + return path.join(' / ') + }) +} + +export function getLocationPathWithLastID(locations: Location[]) { + return locations.map((l) => { + const path: { id: string, name: string }[] = []; + function getPath(loc: Location) { + path.push({ id: loc.id, name: loc.name }) + + if (loc.children) { + loc.children.map(getPath) + } + } + getPath(l) + + return path.map((location, i) => { + if (i + 1 === path.length) { + return { id: location.id, path: path.map(l => l.name).join(' / ') } + } + }).filter(i => i) + }) +} + + +export function getLastLocationPath(locations: Location[]) { + return locations.map((l) => { + const path = []; + function getPath(loc: Location) { + if (loc.children && loc.children.length) { + loc.children.map(getPath) + } else { + path.push(loc.name) + } + } + getPath(l) + return path[0] + }) +} \ No newline at end of file diff --git a/src/utils/getFileExtensionFromContentType.ts b/src/utils/getFileExtensionFromContentType.ts new file mode 100644 index 0000000..a76c51b --- /dev/null +++ b/src/utils/getFileExtensionFromContentType.ts @@ -0,0 +1,20 @@ +// Define a mapping of content types to file extensions +const contentTypeToExtension: { [contentType: string]: string } = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/bmp': '.bmp', + 'image/tiff': '.tiff', + 'image/svg+xml': '.svg', + 'audio/mpeg': '.mp3', + 'audio/wav': '.wav', + 'audio/ogg': '.ogg', + 'video/mp4': '.mp4', + 'video/webm': '.webm', + // Add more content types and their corresponding extensions as needed +}; + +// Function to get the file extension based on the content type +export const getFileExtensionFromContentType = (contentType: string): string | undefined => { + return contentTypeToExtension[contentType]; +} \ No newline at end of file