diff --git a/website/package-lock.json b/website/package-lock.json index 07c67f34..cb7c020f 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -21,9 +21,11 @@ "jest-environment-jsdom": "^30.0.0-beta.3", "lodash": "^4.17.21", "lozad": "^1.16.0", + "mongodb": "^6.17.0", "normalize.css": "^8.0.1", "postmark": "^4.0.5", "swiper": "^11.2.6", + "yargs": "^17.7.2", "yup": "^1.6.1" }, "devDependencies": { @@ -6383,9 +6385,9 @@ } }, "node_modules/bson": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.3.tgz", - "integrity": "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ==", + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", "license": "Apache-2.0", "engines": { "node": ">=16.20.1" @@ -6701,7 +6703,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -6716,14 +6717,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6738,7 +6737,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -9884,7 +9882,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -12874,13 +12871,13 @@ "optional": true }, "node_modules/mongodb": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.16.0.tgz", - "integrity": "sha512-D1PNcdT0y4Grhou5Zi/qgipZOYeWrhLEpk33n3nm6LGtz61jvO88WlrWCK/bigMjpnOdAUKKQwsGIl0NtWMyYw==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.17.0.tgz", + "integrity": "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA==", "license": "Apache-2.0", "dependencies": { "@mongodb-js/saslprep": "^1.1.9", - "bson": "^6.10.3", + "bson": "^6.10.4", "mongodb-connection-string-url": "^3.0.0" }, "engines": { @@ -14980,7 +14977,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -18107,7 +18103,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -18124,7 +18119,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -18143,7 +18137,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -18153,14 +18146,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", diff --git a/website/package.json b/website/package.json index e1888f1d..e25bf9c9 100644 --- a/website/package.json +++ b/website/package.json @@ -50,9 +50,11 @@ "jest-environment-jsdom": "^30.0.0-beta.3", "lodash": "^4.17.21", "lozad": "^1.16.0", + "mongodb": "^6.17.0", "normalize.css": "^8.0.1", "postmark": "^4.0.5", "swiper": "^11.2.6", + "yargs": "^17.7.2", "yup": "^1.6.1" }, "devDependencies": { diff --git a/website/scripts/migrate-field-type.js b/website/scripts/migrate-field-type.js new file mode 100644 index 00000000..458f8a09 --- /dev/null +++ b/website/scripts/migrate-field-type.js @@ -0,0 +1,122 @@ +const { MongoClient } = require('mongodb'); +const { + stripHtml, + areaToString, + updateTestimonialFeedback, + updateTableRowsDescriptions, +} = require('./migrate-field-type.utils'); + +const getCollection = async ( + mongoUri = 'mongodb://localhost:27017', + dbName = 'test', +) => { + const client = new MongoClient(mongoUri); + await client.connect(); + const db = client.db(dbName); + const collection = db.collection('aposDocs'); + return { client, collection }; +}; + +const processBatches = async ( + batches, + idKey, + collection, + updateFn = updateTestimonialFeedback, +) => { + let updatedCount = 0; + const allPromises = []; + for (const batch of batches) { + allPromises.push(...batch.map((doc) => updateFn(collection, doc, idKey))); + } + const results = await Promise.all(allPromises); + updatedCount = results.reduce((sum, value) => sum + value, 0); + return updatedCount; +}; + +const migrateTestimonialFeedbackToString = async (mongoUri, dbName) => { + const { client, collection } = await getCollection(mongoUri, dbName); + try { + const documents = await collection.find({ type: 'testimonials' }).toArray(); + const idKey = '_id'; + const batchSize = 10; + const batches = []; + for (let i = 0; i < documents.length; i += batchSize) { + batches.push(documents.slice(i, i + batchSize)); + } + const updatedCount = await processBatches(batches, idKey, collection); + return updatedCount; + } finally { + await client.close(); + } +}; + +const migrateTableDescriptions = async (mongoUri, dbName) => { + const { client, collection } = await getCollection(mongoUri, dbName); + try { + const docs = await collection + .find({ 'main.items.type': 'table' }) + .toArray(); + const idKey = '_id'; + const batchSize = 10; + const batches = []; + for (let i = 0; i < docs.length; i += batchSize) { + batches.push(docs.slice(i, i + batchSize)); + } + const updatedCount = await processBatches( + batches, + idKey, + collection, + updateTableRowsDescriptions, + ); + return updatedCount; + } finally { + await client.close(); + } +}; + +if (require.main === module) { + const yargs = require('yargs/yargs'); + const { hideBin } = require('yargs/helpers'); + const { argv } = yargs(hideBin(process.argv)) + .option('mongoUri', { + describe: 'MongoDB connection URI', + type: 'string', + demandOption: true, + }) + .option('dbName', { + describe: 'MongoDB database name', + type: 'string', + demandOption: true, + }) + .help() + .alias('help', 'h'); + + const MONGODB_URI = argv.mongoUri; + const DB_NAME = argv.dbName; + + (async () => { + try { + const testimonials = await migrateTestimonialFeedbackToString( + MONGODB_URI, + DB_NAME, + ); + process.stdout.write(`Updated testimonials: ${testimonials}\n`); + const tables = await migrateTableDescriptions(MONGODB_URI, DB_NAME); + process.stdout.write(`Updated table rows: ${tables}\n`); + } catch (error) { + process.stdout.write(`Migration error: ${error}\n`); + throw error; + } + })(); +} + +module.exports = { + stripHtml, + areaToString, + updateTestimonialFeedback, + updateTableRowsDescriptions, + getCollection, + processBatches, + migrateTestimonialFeedbackToString, + migrateTableDescriptions, +}; diff --git a/website/scripts/migrate-field-type.test.js b/website/scripts/migrate-field-type.test.js new file mode 100644 index 00000000..71c0580c --- /dev/null +++ b/website/scripts/migrate-field-type.test.js @@ -0,0 +1,210 @@ +const { + stripHtml, + areaToString, + updateTestimonialFeedback, + updateTableRowsDescriptions, +} = require('./migrate-field-type.utils'); + +const { + getCollection, + processBatches, + migrateTestimonialFeedbackToString, + migrateTableDescriptions, +} = require('./migrate-field-type'); + +jest.mock('mongodb', () => { + const mCollection = { + find: jest.fn(), + toArray: jest.fn(), + updateOne: jest.fn(), + }; + const mDb = { collection: jest.fn(() => mCollection), close: jest.fn() }; + const mClient = { + connect: jest.fn().mockResolvedValue(), + db: jest.fn(() => mDb), + close: jest.fn().mockResolvedValue(), + }; + return { MongoClient: jest.fn(() => mClient) }; +}); + +describe('stripHtml', () => { + test('removes

and tags and their closing tags', () => { + expect(stripHtml('

Text

')).toBe('Text'); + expect(stripHtml('Text')).toBe('Text'); + expect(stripHtml('

Nested

')).toBe('Nested'); + expect(stripHtml('No tags')).toBe('No tags'); + }); + + test('returns empty string for non-string input', () => { + expect(stripHtml(null)).toBe(''); + expect(stripHtml(undefined)).toBe(''); + expect(stripHtml(123)).toBe(''); + }); +}); + +describe('areaToString', () => { + test('returns string as is (after stripHtml)', () => { + expect(areaToString('

abc

')).toBe('abc'); + }); + + test('joins items content and strips HTML', () => { + const area = { + items: [{ content: '

foo

' }, { content: 'bar' }], + }; + expect(areaToString(area)).toBe('foo bar'); + }); + + test('returns empty string for empty or invalid area', () => { + expect(areaToString({ items: [] })).toBe(''); + expect(areaToString({})).toBe(''); + expect(areaToString(null)).toBe(''); + }); +}); + +describe('updateTestimonialFeedback', () => { + test('updates feedback if it is an area object', async () => { + const collection = { updateOne: jest.fn().mockResolvedValue({}) }; + const doc = { _id: 1, feedback: { items: [{ content: '

abc

' }] } }; + const result = await updateTestimonialFeedback(collection, doc, '_id'); + expect(result).toBe(1); + expect(collection.updateOne).toHaveBeenCalledWith( + { _id: 1 }, + { $set: { feedback: 'abc' } }, + ); + }); + + test('does not update if feedback is already string', async () => { + const collection = { updateOne: jest.fn() }; + const doc = { _id: 2, feedback: 'already string' }; + const result = await updateTestimonialFeedback(collection, doc, '_id'); + expect(result).toBe(0); + expect(collection.updateOne).not.toHaveBeenCalled(); + }); +}); + +describe('updateTableRowsDesc', () => { + test('updates table row descriptions if they are area objects', async () => { + const collection = { updateOne: jest.fn().mockResolvedValue({}) }; + const doc = { + _id: 1, + main: { + items: [ + { + type: 'table', + rows: [ + { description: { items: [{ content: '

foo

' }] } }, + { description: 'bar' }, + ], + }, + ], + }, + }; + const result = await updateTableRowsDescriptions(collection, doc, '_id'); + expect(result).toBe(1); + expect(collection.updateOne).toHaveBeenCalledWith( + { _id: 1 }, + { + $set: { + 'main.items': [ + { + type: 'table', + rows: [{ description: 'foo' }, { description: 'bar' }], + }, + ], + }, + }, + ); + }); + + test('does not update if no area descriptions found', async () => { + const collection = { updateOne: jest.fn() }; + const doc = { + _id: 2, + main: { items: [{ type: 'table', rows: [{ description: 'plain' }] }] }, + }; + const result = await updateTableRowsDescriptions(collection, doc, '_id'); + expect(result).toBe(0); + expect(collection.updateOne).not.toHaveBeenCalled(); + }); +}); + +describe('getCollection', () => { + test('returns client and collection', async () => { + const { client, collection } = await getCollection( + 'mongodb://test', + 'testdb', + ); + expect(client).toBeDefined(); + expect(collection).toBeDefined(); + }); +}); + +describe('processBatches', () => { + test('calls updateFn for each doc and sums results', async () => { + const updateFn = jest.fn().mockResolvedValue(1); + const collection = {}; + const batches = [[{ id: 1 }, { id: 2 }], [{ id: 3 }]]; + const result = await processBatches(batches, 'id', collection, updateFn); + expect(updateFn).toHaveBeenCalledTimes(3); + expect(result).toBe(3); + }); + + test('returns 0 if all updateFn return 0', async () => { + const updateFn = jest.fn().mockResolvedValue(0); + const collection = {}; + const batches = [[{ id: 1 }], [{ id: 2 }]]; + const result = await processBatches(batches, 'id', collection, updateFn); + expect(result).toBe(0); + }); +}); + +describe('migrateTestimonialFeedbackToString', () => { + test('processes testimonial docs and returns updated count', async () => { + const { MongoClient } = require('mongodb'); + const mClient = MongoClient(); + const mCollection = mClient.db().collection(); + mCollection.find.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([ + { _id: 1, feedback: { items: [{ content: '

abc

' }] } }, + { _id: 2, feedback: 'already string' }, + ]), + }); + mCollection.updateOne.mockResolvedValue({}); + const updated = await migrateTestimonialFeedbackToString( + 'mongodb://test', + 'testdb', + ); + expect(updated).toBeGreaterThanOrEqual(0); + expect(mClient.close).toHaveBeenCalled(); + }); +}); + +describe('migrateTableDescriptions', () => { + test('processes table docs and returns updated count', async () => { + const { MongoClient } = require('mongodb'); + const mClient = MongoClient(); + const mCollection = mClient.db().collection(); + mCollection.find.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([ + { + _id: 1, + main: { + items: [ + { + type: 'table', + rows: [ + { description: { items: [{ content: '

foo

' }] } }, + { description: 'bar' }, + ], + }, + ], + }, + }, + ]), + }); + mCollection.updateOne.mockResolvedValue({}); + const updated = await migrateTableDescriptions('mongodb://test', 'testdb'); + expect(updated).toBeGreaterThanOrEqual(0); + expect(mClient.close).toHaveBeenCalled(); + }); +}); diff --git a/website/scripts/migrate-field-type.utils.js b/website/scripts/migrate-field-type.utils.js new file mode 100644 index 00000000..14a883bd --- /dev/null +++ b/website/scripts/migrate-field-type.utils.js @@ -0,0 +1,93 @@ +const stripHtml = (html) => { + if (typeof html !== 'string') return ''; + let temp = html; + while (temp.includes('', start); + if (end === -1) break; + temp = temp.slice(0, start) + temp.slice(end + 1); + } + temp = temp.replaceAll('

', '').replaceAll('', ''); + return temp; +}; + +const areaToString = (areaData) => { + if (typeof areaData === 'string') return stripHtml(areaData); + if (areaData?.items?.length > 0) { + return areaData.items + .map(function (item) { + if (typeof item.content === 'string') { + return stripHtml(item.content); + } + return ''; + }) + .join(' ') + .trim(); + } + return ''; +}; + +const updateTestimonialFeedback = async function (collection, doc, idKey) { + if ( + typeof doc.feedback !== 'string' && + doc.feedback && + typeof doc.feedback === 'object' + ) { + const fixedFeedback = areaToString(doc.feedback); + if (typeof fixedFeedback === 'string' && fixedFeedback.length > 0) { + await collection.updateOne( + { [idKey]: doc[idKey] }, + { $set: { feedback: fixedFeedback } }, + ); + return 1; + } + } + return 0; +}; + +const updateTableRowsDescriptions = async (collection, doc, idKey) => { + let changed = false; + if (doc.main?.items && Array.isArray(doc.main.items)) { + const newItems = doc.main.items.map((widget) => { + if (widget.type === 'table' && Array.isArray(widget.rows)) { + const newRows = widget.rows.map((row) => { + if ( + row && + typeof row.description === 'object' && + row.description !== null && + row.description.items + ) { + changed = true; + return { ...row, description: areaToString(row.description) }; + } + return row; + }); + return { ...widget, rows: newRows }; + } + return widget; + }); + if (changed) { + await collection.updateOne( + { [idKey]: doc[idKey] }, + { $set: { 'main.items': newItems } }, + ); + return 1; + } + } + return 0; +}; + +module.exports = { + stripHtml, + areaToString, + updateTestimonialFeedback, + updateTableRowsDescriptions, +};