diff --git a/backend/src/db/migrations/20260414100000-create-sites-and-site-crawls.js b/backend/src/db/migrations/20260414100000-create-sites-and-site-crawls.js new file mode 100644 index 0000000..35c3dac --- /dev/null +++ b/backend/src/db/migrations/20260414100000-create-sites-and-site-crawls.js @@ -0,0 +1,225 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const sitesRows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.sites') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + const sitesTableName = sitesRows[0].regclass_name; + + if (!sitesTableName) { + await queryInterface.createTable( + 'sites', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + name: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + base_url: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + detected_platform: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + crawl_status: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + last_crawled_at: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + }, + { transaction }, + ); + } + + const siteCrawlsRows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.site_crawls') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + const siteCrawlsTableName = siteCrawlsRows[0].regclass_name; + + if (!siteCrawlsTableName) { + await queryInterface.createTable( + 'site_crawls', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + siteId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'sites', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + status: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + defaultValue: 'pending', + }, + started_at: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + finished_at: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + pages_scanned: { + type: Sequelize.DataTypes.INTEGER, + allowNull: true, + }, + summary: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + }, + { transaction }, + ); + + await queryInterface.addIndex('site_crawls', ['siteId'], { + name: 'site_crawls_siteId_idx', + transaction, + }); + } + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const siteCrawlsRows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.site_crawls') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + const siteCrawlsTableName = siteCrawlsRows[0].regclass_name; + + if (siteCrawlsTableName) { + await queryInterface.dropTable('site_crawls', { transaction }); + } + + const sitesRows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.sites') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + const sitesTableName = sitesRows[0].regclass_name; + + if (sitesTableName) { + await queryInterface.dropTable('sites', { transaction }); + } + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/20260414133000-create-schema-recommendations.js b/backend/src/db/migrations/20260414133000-create-schema-recommendations.js new file mode 100644 index 0000000..052ad3b --- /dev/null +++ b/backend/src/db/migrations/20260414133000-create-schema-recommendations.js @@ -0,0 +1,158 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tableRows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.schema_recommendations') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + const tableName = tableRows[0].regclass_name; + + if (!tableName) { + await queryInterface.createTable( + 'schema_recommendations', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + siteId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'sites', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + crawlId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + key: 'id', + model: 'site_crawls', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + title: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + recommendation_type: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + schema_type: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + page_scope: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + priority: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + reason: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + expected_impact: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + suggested_schema: { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + key: 'id', + model: 'users', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + }, + { transaction }, + ); + + await queryInterface.addIndex('schema_recommendations', ['siteId'], { + name: 'schema_recommendations_siteId_idx', + transaction, + }); + + await queryInterface.addIndex('schema_recommendations', ['crawlId'], { + name: 'schema_recommendations_crawlId_idx', + transaction, + }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tableRows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.schema_recommendations') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + const tableName = tableRows[0].regclass_name; + + if (tableName) { + await queryInterface.dropTable('schema_recommendations', { transaction }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/models/schema_recommendations.js b/backend/src/db/models/schema_recommendations.js new file mode 100644 index 0000000..2bed168 --- /dev/null +++ b/backend/src/db/models/schema_recommendations.js @@ -0,0 +1,84 @@ +module.exports = function (sequelize, DataTypes) { + const schema_recommendations = sequelize.define( + 'schema_recommendations', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + title: { + type: DataTypes.TEXT, + allowNull: false, + }, + + recommendation_type: { + type: DataTypes.TEXT, + }, + + schema_type: { + type: DataTypes.TEXT, + }, + + page_scope: { + type: DataTypes.TEXT, + }, + + priority: { + type: DataTypes.TEXT, + }, + + reason: { + type: DataTypes.TEXT, + }, + + expected_impact: { + type: DataTypes.TEXT, + }, + + suggested_schema: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + schema_recommendations.associate = (db) => { + db.schema_recommendations.belongsTo(db.sites, { + as: 'site', + foreignKey: { + name: 'siteId', + }, + constraints: false, + }); + + db.schema_recommendations.belongsTo(db.site_crawls, { + as: 'crawl', + foreignKey: { + name: 'crawlId', + }, + constraints: false, + }); + + db.schema_recommendations.belongsTo(db.users, { + as: 'createdBy', + }); + + db.schema_recommendations.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return schema_recommendations; +}; diff --git a/backend/src/db/models/site_crawls.js b/backend/src/db/models/site_crawls.js new file mode 100644 index 0000000..2abfa5e --- /dev/null +++ b/backend/src/db/models/site_crawls.js @@ -0,0 +1,73 @@ +module.exports = function (sequelize, DataTypes) { + const site_crawls = sequelize.define( + 'site_crawls', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + status: { + type: DataTypes.TEXT, + allowNull: false, + defaultValue: 'pending', + }, + + started_at: { + type: DataTypes.DATE, + }, + + finished_at: { + type: DataTypes.DATE, + }, + + pages_scanned: { + type: DataTypes.INTEGER, + }, + + summary: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + site_crawls.associate = (db) => { + db.site_crawls.belongsTo(db.sites, { + as: 'site', + foreignKey: { + name: 'siteId', + }, + constraints: false, + }); + + db.site_crawls.hasMany(db.schema_recommendations, { + as: 'schema_recommendations_crawl', + foreignKey: { + name: 'crawlId', + }, + constraints: false, + }); + + db.site_crawls.belongsTo(db.users, { + as: 'createdBy', + }); + + db.site_crawls.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return site_crawls; +}; diff --git a/backend/src/db/models/sites.js b/backend/src/db/models/sites.js new file mode 100644 index 0000000..a2d2287 --- /dev/null +++ b/backend/src/db/models/sites.js @@ -0,0 +1,72 @@ +module.exports = function (sequelize, DataTypes) { + const sites = sequelize.define( + 'sites', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + name: { + type: DataTypes.TEXT, + }, + + base_url: { + type: DataTypes.TEXT, + allowNull: false, + }, + + detected_platform: { + type: DataTypes.TEXT, + }, + + crawl_status: { + type: DataTypes.TEXT, + }, + + last_crawled_at: { + type: DataTypes.DATE, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + sites.associate = (db) => { + db.sites.hasMany(db.site_crawls, { + as: 'site_crawls_site', + foreignKey: { + name: 'siteId', + }, + constraints: false, + }); + + db.sites.hasMany(db.schema_recommendations, { + as: 'schema_recommendations_site', + foreignKey: { + name: 'siteId', + }, + constraints: false, + }); + + db.sites.belongsTo(db.users, { + as: 'createdBy', + }); + + db.sites.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return sites; +}; diff --git a/backend/src/db/seeders/20260414101000-add-site-entities-permissions.js b/backend/src/db/seeders/20260414101000-add-site-entities-permissions.js new file mode 100644 index 0000000..75f1812 --- /dev/null +++ b/backend/src/db/seeders/20260414101000-add-site-entities-permissions.js @@ -0,0 +1,189 @@ +const { v4: uuid } = require('uuid'); + +const ENTITY_ROLE_PERMISSIONS = { + sites: { + Administrator: ['CREATE', 'READ', 'UPDATE', 'DELETE'], + 'Platform Owner': ['CREATE', 'READ', 'UPDATE', 'DELETE'], + 'Security Steward': ['READ', 'UPDATE'], + 'Product Lead': ['CREATE', 'READ', 'UPDATE'], + 'Implementation Manager': ['READ', 'UPDATE'], + 'Support Analyst': ['READ'], + }, + site_crawls: { + Administrator: ['CREATE', 'READ', 'UPDATE', 'DELETE'], + 'Platform Owner': ['CREATE', 'READ', 'UPDATE', 'DELETE'], + 'Security Steward': ['READ', 'UPDATE'], + 'Product Lead': ['CREATE', 'READ', 'UPDATE'], + 'Implementation Manager': ['READ', 'UPDATE'], + 'Support Analyst': ['READ'], + }, +}; + +function getPermissionNames(entityName) { + return ['CREATE', 'READ', 'UPDATE', 'DELETE'].map( + (action) => `${action}_${entityName.toUpperCase()}`, + ); +} + +module.exports = { + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const createdAt = new Date(); + const updatedAt = createdAt; + + const permissionNames = Object.keys(ENTITY_ROLE_PERMISSIONS).flatMap(getPermissionNames); + + const existingPermissions = await queryInterface.sequelize.query( + 'SELECT id, name FROM permissions WHERE name IN (:permissionNames);', + { + replacements: { permissionNames }, + transaction, + type: queryInterface.sequelize.QueryTypes.SELECT, + }, + ); + + const permissionIdByName = new Map( + existingPermissions.map((permission) => [permission.name, permission.id]), + ); + + const missingPermissions = permissionNames + .filter((permissionName) => !permissionIdByName.has(permissionName)) + .map((permissionName) => ({ + id: uuid(), + name: permissionName, + createdAt, + updatedAt, + })); + + if (missingPermissions.length > 0) { + await queryInterface.bulkInsert('permissions', missingPermissions, { transaction }); + missingPermissions.forEach((permission) => { + permissionIdByName.set(permission.name, permission.id); + }); + } + + const roleNames = Array.from( + new Set( + Object.values(ENTITY_ROLE_PERMISSIONS).flatMap((entityPermissions) => + Object.keys(entityPermissions), + ), + ), + ); + + const roles = await queryInterface.sequelize.query( + 'SELECT id, name FROM roles WHERE name IN (:roleNames);', + { + replacements: { roleNames }, + transaction, + type: queryInterface.sequelize.QueryTypes.SELECT, + }, + ); + + const roleIdByName = new Map(roles.map((role) => [role.name, role.id])); + + const desiredPairs = []; + Object.entries(ENTITY_ROLE_PERMISSIONS).forEach(([entityName, rolePermissions]) => { + Object.entries(rolePermissions).forEach(([roleName, actions]) => { + actions.forEach((action) => { + desiredPairs.push({ + roles_permissionsId: roleIdByName.get(roleName), + permissionId: permissionIdByName.get(`${action}_${entityName.toUpperCase()}`), + }); + }); + }); + }); + + const roleIds = Array.from(roleIdByName.values()).filter(Boolean); + const permissionIds = Array.from(permissionIdByName.values()).filter(Boolean); + + let existingPairs = []; + if (roleIds.length > 0 && permissionIds.length > 0) { + existingPairs = await queryInterface.sequelize.query( + `SELECT "roles_permissionsId", "permissionId" + FROM "rolesPermissionsPermissions" + WHERE "roles_permissionsId" IN (:roleIds) + AND "permissionId" IN (:permissionIds);`, + { + replacements: { roleIds, permissionIds }, + transaction, + type: queryInterface.sequelize.QueryTypes.SELECT, + }, + ); + } + + const existingPairKeys = new Set( + existingPairs.map( + (pair) => `${pair.roles_permissionsId}:${pair.permissionId}`, + ), + ); + + const missingPairs = desiredPairs + .filter( + (pair) => + pair.roles_permissionsId + && pair.permissionId + && !existingPairKeys.has(`${pair.roles_permissionsId}:${pair.permissionId}`), + ) + .map((pair) => ({ + ...pair, + createdAt, + updatedAt, + })); + + if (missingPairs.length > 0) { + await queryInterface.bulkInsert('rolesPermissionsPermissions', missingPairs, { + transaction, + }); + } + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const permissionNames = Object.keys(ENTITY_ROLE_PERMISSIONS).flatMap(getPermissionNames); + + const permissions = await queryInterface.sequelize.query( + 'SELECT id FROM permissions WHERE name IN (:permissionNames);', + { + replacements: { permissionNames }, + transaction, + type: queryInterface.sequelize.QueryTypes.SELECT, + }, + ); + + const permissionIds = permissions.map((permission) => permission.id); + + if (permissionIds.length > 0) { + await queryInterface.bulkDelete( + 'rolesPermissionsPermissions', + { + permissionId: permissionIds, + }, + { transaction }, + ); + + await queryInterface.bulkDelete( + 'permissions', + { + id: permissionIds, + }, + { transaction }, + ); + } + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/seeders/20260414113000-add-administrator-site-permissions.js b/backend/src/db/seeders/20260414113000-add-administrator-site-permissions.js new file mode 100644 index 0000000..e4dfd3a --- /dev/null +++ b/backend/src/db/seeders/20260414113000-add-administrator-site-permissions.js @@ -0,0 +1,131 @@ +const ROLE_NAME = 'Administrator'; +const PERMISSION_NAMES = [ + 'CREATE_SITES', + 'READ_SITES', + 'UPDATE_SITES', + 'DELETE_SITES', + 'CREATE_SITE_CRAWLS', + 'READ_SITE_CRAWLS', + 'UPDATE_SITE_CRAWLS', + 'DELETE_SITE_CRAWLS', +]; + +module.exports = { + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const createdAt = new Date(); + const updatedAt = createdAt; + + const role = await queryInterface.sequelize.query( + 'SELECT id FROM roles WHERE name = :roleName LIMIT 1;', + { + replacements: { roleName: ROLE_NAME }, + transaction, + type: queryInterface.sequelize.QueryTypes.SELECT, + }, + ); + + if (!role[0]) { + await transaction.commit(); + return; + } + + const permissions = await queryInterface.sequelize.query( + 'SELECT id, name FROM permissions WHERE name IN (:permissionNames);', + { + replacements: { permissionNames: PERMISSION_NAMES }, + transaction, + type: queryInterface.sequelize.QueryTypes.SELECT, + }, + ); + + const existingPairs = await queryInterface.sequelize.query( + `SELECT "permissionId" + FROM "rolesPermissionsPermissions" + WHERE "roles_permissionsId" = :roleId + AND "permissionId" IN (:permissionIds);`, + { + replacements: { + roleId: role[0].id, + permissionIds: permissions.map((permission) => permission.id), + }, + transaction, + type: queryInterface.sequelize.QueryTypes.SELECT, + }, + ); + + const existingPermissionIds = new Set( + existingPairs.map((pair) => pair.permissionId), + ); + + const missingPairs = permissions + .filter((permission) => !existingPermissionIds.has(permission.id)) + .map((permission) => ({ + createdAt, + updatedAt, + roles_permissionsId: role[0].id, + permissionId: permission.id, + })); + + if (missingPairs.length > 0) { + await queryInterface.bulkInsert( + 'rolesPermissionsPermissions', + missingPairs, + { transaction }, + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const role = await queryInterface.sequelize.query( + 'SELECT id FROM roles WHERE name = :roleName LIMIT 1;', + { + replacements: { roleName: ROLE_NAME }, + transaction, + type: queryInterface.sequelize.QueryTypes.SELECT, + }, + ); + + if (!role[0]) { + await transaction.commit(); + return; + } + + const permissions = await queryInterface.sequelize.query( + 'SELECT id FROM permissions WHERE name IN (:permissionNames);', + { + replacements: { permissionNames: PERMISSION_NAMES }, + transaction, + type: queryInterface.sequelize.QueryTypes.SELECT, + }, + ); + + if (permissions.length > 0) { + await queryInterface.bulkDelete( + 'rolesPermissionsPermissions', + { + roles_permissionsId: role[0].id, + permissionId: permissions.map((permission) => permission.id), + }, + { transaction }, + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/seeders/20260414114500-apply-administrator-site-permissions.js b/backend/src/db/seeders/20260414114500-apply-administrator-site-permissions.js new file mode 100644 index 0000000..2f30665 --- /dev/null +++ b/backend/src/db/seeders/20260414114500-apply-administrator-site-permissions.js @@ -0,0 +1,136 @@ +const ROLE_NAME = 'Administrator'; +const PERMISSION_NAMES = [ + 'CREATE_SITES', + 'READ_SITES', + 'UPDATE_SITES', + 'DELETE_SITES', + 'CREATE_SITE_CRAWLS', + 'READ_SITE_CRAWLS', + 'UPDATE_SITE_CRAWLS', + 'DELETE_SITE_CRAWLS', +]; + +module.exports = { + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const createdAt = new Date(); + const updatedAt = createdAt; + + const role = await queryInterface.sequelize.query( + 'SELECT id FROM roles WHERE name = :roleName LIMIT 1;', + { + replacements: { roleName: ROLE_NAME }, + transaction, + type: queryInterface.sequelize.QueryTypes.SELECT, + }, + ); + + if (!role[0]) { + await transaction.commit(); + return; + } + + const permissions = await queryInterface.sequelize.query( + 'SELECT id FROM permissions WHERE name IN (:permissionNames);', + { + replacements: { permissionNames: PERMISSION_NAMES }, + transaction, + type: queryInterface.sequelize.QueryTypes.SELECT, + }, + ); + + if (permissions.length === 0) { + await transaction.commit(); + return; + } + + const existingPairs = await queryInterface.sequelize.query( + `SELECT "permissionId" + FROM "rolesPermissionsPermissions" + WHERE "roles_permissionsId" = :roleId + AND "permissionId" IN (:permissionIds);`, + { + replacements: { + roleId: role[0].id, + permissionIds: permissions.map((permission) => permission.id), + }, + transaction, + type: queryInterface.sequelize.QueryTypes.SELECT, + }, + ); + + const existingPermissionIds = new Set( + existingPairs.map((pair) => pair.permissionId), + ); + + const missingPairs = permissions + .filter((permission) => !existingPermissionIds.has(permission.id)) + .map((permission) => ({ + createdAt, + updatedAt, + roles_permissionsId: role[0].id, + permissionId: permission.id, + })); + + if (missingPairs.length > 0) { + await queryInterface.bulkInsert( + 'rolesPermissionsPermissions', + missingPairs, + { transaction }, + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const role = await queryInterface.sequelize.query( + 'SELECT id FROM roles WHERE name = :roleName LIMIT 1;', + { + replacements: { roleName: ROLE_NAME }, + transaction, + type: queryInterface.sequelize.QueryTypes.SELECT, + }, + ); + + if (!role[0]) { + await transaction.commit(); + return; + } + + const permissions = await queryInterface.sequelize.query( + 'SELECT id FROM permissions WHERE name IN (:permissionNames);', + { + replacements: { permissionNames: PERMISSION_NAMES }, + transaction, + type: queryInterface.sequelize.QueryTypes.SELECT, + }, + ); + + if (permissions.length > 0) { + await queryInterface.bulkDelete( + 'rolesPermissionsPermissions', + { + roles_permissionsId: role[0].id, + permissionId: permissions.map((permission) => permission.id), + }, + { transaction }, + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/index.js b/backend/src/index.js index a935270..3f35728 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -43,6 +43,8 @@ const commentsRoutes = require('./routes/comments'); const audit_logsRoutes = require('./routes/audit_logs'); +const sitesRoutes = require('./routes/sites'); + const getBaseUrl = (url) => { if (!url) return ''; @@ -123,6 +125,8 @@ app.use('/api/comments', passport.authenticate('jwt', {session: false}), comment app.use('/api/audit_logs', passport.authenticate('jwt', {session: false}), audit_logsRoutes); +app.use('/api/sites', passport.authenticate('jwt', {session: false}), sitesRoutes); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/sites.js b/backend/src/routes/sites.js new file mode 100644 index 0000000..2b3a6c7 --- /dev/null +++ b/backend/src/routes/sites.js @@ -0,0 +1,69 @@ +const express = require('express'); + +const SitesService = require('../services/sites'); +const wrapAsync = require('../helpers').wrapAsync; +const { checkPermissions } = require('../middlewares/check-permissions'); + +const router = express.Router(); + +router.post( + '/analyze', + checkPermissions('CREATE_SITES'), + wrapAsync(async (req, res) => { + const payload = await SitesService.analyzeHomepage( + req.body, + req.currentUser, + ); + + res.status(200).send(payload); + }), +); + +router.get( + '/:id/report', + checkPermissions('READ_SITES'), + wrapAsync(async (req, res) => { + const payload = await SitesService.getLatestReport( + req.params.id, + req.currentUser, + ); + + res.status(200).send(payload); + }), +); + +router.post( + '/export', + checkPermissions('READ_SITES'), + wrapAsync(async (req, res) => { + const payload = await SitesService.exportCode( + req.body, + req.currentUser, + ); + + res.setHeader('Content-Type', payload.contentType); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${payload.filename}"`, + ); + + res.status(200).send(payload.content); + }), +); + +router.post( + '/email-code', + checkPermissions('READ_SITES'), + wrapAsync(async (req, res) => { + const payload = await SitesService.emailCode( + req.body, + req.currentUser, + ); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/sites.js b/backend/src/services/sites.js new file mode 100644 index 0000000..626e5e1 --- /dev/null +++ b/backend/src/services/sites.js @@ -0,0 +1,1090 @@ +const axios = require('axios'); +const db = require('../db/models'); +const ValidationError = require('./notifications/errors/validation'); +const EmailSender = require('./email'); + +const REQUEST_TIMEOUT = 15000; +const PREVIEW_LIMIT = 5; + +function normalizeUrl(rawUrl) { + if (!rawUrl || typeof rawUrl !== 'string') { + throw new ValidationError('errors.validation.message'); + } + + const trimmedUrl = rawUrl.trim(); + const candidateUrl = /^https?:\/\//i.test(trimmedUrl) + ? trimmedUrl + : `https://${trimmedUrl}`; + + let parsedUrl; + + try { + parsedUrl = new URL(candidateUrl); + } catch (error) { + throw new ValidationError('errors.validation.message'); + } + + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + throw new ValidationError('errors.validation.message'); + } + + parsedUrl.hash = ''; + + if (parsedUrl.pathname === '/') { + parsedUrl.pathname = ''; + } + + return parsedUrl.toString().replace(/\/$/, ''); +} + +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function extractPageTitle(html) { + const titleMatch = html.match(/]*>([\s\S]*?)<\/title>/i); + + if (!titleMatch) { + return null; + } + + return titleMatch[1].replace(/\s+/g, ' ').trim() || null; +} + +function addJsonLdTypes(node, types) { + if (!node) { + return; + } + + if (Array.isArray(node)) { + node.forEach((entry) => addJsonLdTypes(entry, types)); + return; + } + + if (typeof node !== 'object') { + return; + } + + if (node['@type']) { + const typeValue = node['@type']; + const normalizedTypes = Array.isArray(typeValue) + ? typeValue + : [typeValue]; + + normalizedTypes + .filter(Boolean) + .forEach((type) => types.add(String(type))); + } + + Object.values(node).forEach((value) => addJsonLdTypes(value, types)); +} + +function extractSchemaSummary(html) { + const jsonLdMatches = [ + ...html.matchAll( + /]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi, + ), + ]; + + const jsonLdTypes = new Set(); + const jsonLdErrors = []; + + jsonLdMatches.forEach((match, index) => { + const rawBlock = match[1] ? match[1].trim() : ''; + + if (!rawBlock) { + return; + } + + try { + const parsedBlock = JSON.parse(rawBlock); + addJsonLdTypes(parsedBlock, jsonLdTypes); + } catch (error) { + jsonLdErrors.push({ + index, + message: error.message, + }); + } + }); + + const microdataCount = (html.match(/\sitemscope(?:\s|=|>)/gi) || []).length; + const rdfaTypeofCount = (html.match(/\stypeof\s*=\s*(["']).*?\1/gi) || []).length; + const rdfaPropertyCount = (html.match(/\sproperty\s*=\s*(["']).*?\1/gi) || []).length; + const rdfaVocabCount = (html.match(/\svocab\s*=\s*(["']).*?\1/gi) || []).length; + const rdfaCount = Math.max( + rdfaTypeofCount, + rdfaPropertyCount, + rdfaVocabCount, + ); + + return { + hasStructuredData: + jsonLdMatches.length > 0 || microdataCount > 0 || rdfaCount > 0, + jsonLd: { + count: jsonLdMatches.length, + types: Array.from(jsonLdTypes), + invalidBlocks: jsonLdErrors, + }, + microdata: { + count: microdataCount, + detected: microdataCount > 0, + }, + rdfa: { + count: rdfaCount, + detected: rdfaCount > 0, + }, + }; +} + +function detectPlatform(html, headers, analyzedUrl) { + const lowerHtml = html.toLowerCase(); + const serverHeader = String(headers.server || '').toLowerCase(); + const poweredByHeader = String(headers['x-powered-by'] || '').toLowerCase(); + const hostname = new URL(analyzedUrl).hostname.toLowerCase(); + + const platformChecks = [ + { + key: 'wordpress', + label: 'WordPress', + signals: [ + 'wp-content', + 'wp-includes', + 'wp-json', + 'content="wordpress', + ], + headerSignals: [], + }, + { + key: 'shopify', + label: 'Shopify', + signals: [ + 'cdn.shopify.com', + 'shopify.theme', + 'shopify-section', + '.myshopify.com', + ], + headerSignals: [], + }, + { + key: 'webflow', + label: 'Webflow', + signals: [ + 'webflow', + 'data-wf-page', + 'data-wf-site', + 'webflow.require', + ], + headerSignals: [], + }, + { + key: 'wix', + label: 'Wix', + signals: ['wixstatic.com', 'static.wixstatic.com'], + headerSignals: ['x-wix-request-id'], + }, + { + key: 'squarespace', + label: 'Squarespace', + signals: ['static.squarespace.com', 'squarespace-cdn.com'], + headerSignals: [], + }, + ]; + + for (const platform of platformChecks) { + const matchedSignals = platform.signals.filter( + (signal) => lowerHtml.includes(signal) || hostname.includes(signal), + ); + const matchedHeaders = platform.headerSignals.filter( + (headerSignal) => Object.keys(headers).includes(headerSignal), + ); + + if (matchedSignals.length > 0 || matchedHeaders.length > 0) { + return { + detected: platform.key, + label: platform.label, + matchedSignals: [...matchedSignals, ...matchedHeaders], + }; + } + } + + const genericSignals = []; + + if (serverHeader) { + genericSignals.push(`server:${serverHeader}`); + } + + if (poweredByHeader) { + genericSignals.push(`x-powered-by:${poweredByHeader}`); + } + + return { + detected: 'custom', + label: 'Custom / Unknown', + matchedSignals: genericSignals, + }; +} + +function buildFailureAnalysis(normalizedUrl, error) { + const isAxiosError = axios.isAxiosError(error); + + return { + requestedUrl: normalizedUrl, + analyzedUrl: normalizedUrl, + fetchedAt: new Date().toISOString(), + platform: { + detected: 'unknown', + label: 'Unknown', + matchedSignals: [], + }, + schema: { + hasStructuredData: false, + jsonLd: { count: 0, types: [], invalidBlocks: [] }, + microdata: { count: 0, detected: false }, + rdfa: { count: 0, detected: false }, + }, + error: isAxiosError + ? error.response + ? `Request failed with status ${error.response.status}` + : error.message + : error.message, + }; +} + +function normalizeSchemaType(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/^https?:\/\/schema\.org\//, '') + .replace(/^schema:/, ''); +} + +function hasSchemaType(types, candidates) { + const normalizedTypes = new Set((types || []).map(normalizeSchemaType)); + return candidates.some((candidate) => normalizedTypes.has(normalizeSchemaType(candidate))); +} + +function inferPageSignals(html, analyzedUrl, pageTitle, platform) { + const lowerHtml = String(html || '').toLowerCase(); + const lowerTitle = String(pageTitle || '').toLowerCase(); + const lowerUrl = String(analyzedUrl || '').toLowerCase(); + const combined = `${lowerTitle} ${lowerUrl} ${lowerHtml}`; + + return { + hasFaqHints: + combined.includes('faq') || + combined.includes('frequently asked questions') || + lowerHtml.includes('accordion'), + hasBlogHints: + combined.includes('/blog') || + combined.includes('blog') || + combined.includes('article') || + combined.includes('author') || + combined.includes('published'), + hasProductHints: + combined.includes('add to cart') || + combined.includes('sku') || + combined.includes('price') || + combined.includes('product') || + platform.detected === 'shopify', + hasLocalBusinessHints: + combined.includes('opening hours') || + combined.includes('our location') || + combined.includes('visit us') || + combined.includes('call us') || + combined.includes('directions'), + }; +} + +function toPrettyJson(value) { + return JSON.stringify(value, null, 2); +} + +function buildRecommendationCode({ baseUrl, siteName, schemaType, pageScope }) { + const hostname = new URL(baseUrl).hostname; + + if (schemaType === 'Organization') { + return toPrettyJson({ + '@context': 'https://schema.org', + '@type': 'Organization', + name: siteName, + url: baseUrl, + logo: `${baseUrl}/path-to-logo.png`, + sameAs: ['https://www.linkedin.com/company/your-brand'], + }); + } + + if (schemaType === 'LocalBusiness') { + return toPrettyJson({ + '@context': 'https://schema.org', + '@type': 'LocalBusiness', + name: siteName, + url: baseUrl, + telephone: '{{business_phone}}', + address: { + '@type': 'PostalAddress', + streetAddress: '{{street_address}}', + addressLocality: '{{city}}', + addressRegion: '{{state_or_region}}', + postalCode: '{{postal_code}}', + addressCountry: '{{country_code}}', + }, + }); + } + + if (schemaType === 'WebSite') { + return toPrettyJson({ + '@context': 'https://schema.org', + '@type': 'WebSite', + name: siteName, + url: baseUrl, + inLanguage: 'en', + }); + } + + if (schemaType === 'BreadcrumbList') { + return toPrettyJson({ + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: 'Home', + item: baseUrl, + }, + { + '@type': 'ListItem', + position: 2, + name: `{{${pageScope || 'page'}_section_name}}`, + item: `{{${pageScope || 'page'}_section_url}}`, + }, + { + '@type': 'ListItem', + position: 3, + name: '{{current_page_name}}', + item: '{{current_page_url}}', + }, + ], + }); + } + + if (schemaType === 'Product') { + return toPrettyJson({ + '@context': 'https://schema.org', + '@type': 'Product', + name: '{{product_name}}', + image: ['{{product_image_url}}'], + description: '{{product_description}}', + sku: '{{product_sku}}', + brand: { + '@type': 'Brand', + name: siteName, + }, + offers: { + '@type': 'Offer', + url: '{{product_page_url}}', + priceCurrency: '{{currency}}', + price: '{{price}}', + availability: 'https://schema.org/InStock', + }, + }); + } + + if (schemaType === 'BlogPosting') { + return toPrettyJson({ + '@context': 'https://schema.org', + '@type': 'BlogPosting', + headline: '{{article_headline}}', + description: '{{article_description}}', + image: ['{{article_image_url}}'], + author: { + '@type': 'Person', + name: '{{author_name}}', + }, + publisher: { + '@type': 'Organization', + name: siteName, + logo: { + '@type': 'ImageObject', + url: `${baseUrl}/path-to-logo.png`, + }, + }, + mainEntityOfPage: '{{article_url}}', + datePublished: '{{published_iso_date}}', + dateModified: '{{modified_iso_date}}', + }); + } + + if (schemaType === 'FAQPage') { + return toPrettyJson({ + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: [ + { + '@type': 'Question', + name: '{{question_1}}', + acceptedAnswer: { + '@type': 'Answer', + text: '{{answer_1}}', + }, + }, + ], + }); + } + + return toPrettyJson({ + '@context': 'https://schema.org', + '@type': schemaType, + url: baseUrl, + name: siteName || hostname, + }); +} + +function buildRecommendations({ baseUrl, siteName, analysis, html }) { + const recommendationList = []; + const schemaTypes = analysis?.schema?.jsonLd?.types || []; + const pageSignals = inferPageSignals( + html, + analysis?.analyzedUrl, + analysis?.pageTitle, + analysis?.platform || {}, + ); + + if ((analysis?.schema?.jsonLd?.invalidBlocks || []).length > 0) { + recommendationList.push({ + title: 'Fix invalid JSON-LD blocks already on the homepage', + recommendation_type: 'fix_existing', + schema_type: 'JSON-LD', + page_scope: 'homepage', + priority: 'high', + reason: + 'At least one JSON-LD block could not be parsed. Invalid structured data can prevent search engines from using your markup.', + expected_impact: + 'Restores trust in existing structured data and improves the chance of eligibility for rich results.', + suggested_schema: null, + }); + } + + if ( + !hasSchemaType(schemaTypes, ['Organization', 'Corporation', 'LocalBusiness']) + ) { + const schemaType = pageSignals.hasLocalBusinessHints + ? 'LocalBusiness' + : 'Organization'; + + recommendationList.push({ + title: `Add ${schemaType} schema to the homepage`, + recommendation_type: 'missing_foundational', + schema_type: schemaType, + page_scope: 'homepage', + priority: 'high', + reason: + 'The homepage does not expose a clear business identity entity, which makes it harder for search engines and AI systems to understand who operates the site.', + expected_impact: + 'Improves brand/entity understanding and creates a cleaner foundation for other schema types.', + suggested_schema: buildRecommendationCode({ + baseUrl, + siteName, + schemaType, + pageScope: 'homepage', + }), + }); + } + + if (!hasSchemaType(schemaTypes, ['WebSite'])) { + recommendationList.push({ + title: 'Add WebSite schema for the primary domain', + recommendation_type: 'missing_foundational', + schema_type: 'WebSite', + page_scope: 'sitewide', + priority: 'high', + reason: + 'A WebSite entity helps describe the domain itself and complements business-level markup.', + expected_impact: + 'Improves overall understanding of the site as a web property and can support downstream search features.', + suggested_schema: buildRecommendationCode({ + baseUrl, + siteName, + schemaType: 'WebSite', + pageScope: 'sitewide', + }), + }); + } + + if (!hasSchemaType(schemaTypes, ['BreadcrumbList'])) { + recommendationList.push({ + title: 'Add BreadcrumbList schema on internal pages', + recommendation_type: 'missing_navigation', + schema_type: 'BreadcrumbList', + page_scope: 'internal-pages', + priority: 'medium', + reason: + 'Breadcrumb schema helps search engines understand content hierarchy and page relationships.', + expected_impact: + 'Can improve result presentation and site structure understanding.', + suggested_schema: buildRecommendationCode({ + baseUrl, + siteName, + schemaType: 'BreadcrumbList', + pageScope: 'breadcrumb', + }), + }); + } + + if ( + pageSignals.hasProductHints && + !hasSchemaType(schemaTypes, ['Product']) + ) { + recommendationList.push({ + title: 'Add Product schema on product detail pages', + recommendation_type: 'missing_page_type', + schema_type: 'Product', + page_scope: 'product-pages', + priority: 'high', + reason: + 'The site shows product/ecommerce signals, but no Product schema was detected on the analyzed page.', + expected_impact: + 'Improves eligibility for product-rich search experiences and helps AI systems interpret commercial details.', + suggested_schema: buildRecommendationCode({ + baseUrl, + siteName, + schemaType: 'Product', + pageScope: 'product', + }), + }); + } + + if ( + pageSignals.hasBlogHints && + !hasSchemaType(schemaTypes, ['Article', 'BlogPosting', 'NewsArticle']) + ) { + recommendationList.push({ + title: 'Add BlogPosting schema on editorial content', + recommendation_type: 'missing_page_type', + schema_type: 'BlogPosting', + page_scope: 'article-pages', + priority: 'medium', + reason: + 'The site appears to publish editorial content, but article-level schema was not detected.', + expected_impact: + 'Clarifies content ownership, publication dates, and headline structure for search engines and answer engines.', + suggested_schema: buildRecommendationCode({ + baseUrl, + siteName, + schemaType: 'BlogPosting', + pageScope: 'article', + }), + }); + } + + if ( + pageSignals.hasFaqHints && + !hasSchemaType(schemaTypes, ['FAQPage']) + ) { + recommendationList.push({ + title: 'Add FAQPage schema where FAQs are published', + recommendation_type: 'missing_page_type', + schema_type: 'FAQPage', + page_scope: 'faq-pages', + priority: 'medium', + reason: + 'FAQ content signals were found, but the page is not marked up as an FAQPage.', + expected_impact: + 'Makes question-and-answer content easier to parse and reuse in search and AI contexts.', + suggested_schema: buildRecommendationCode({ + baseUrl, + siteName, + schemaType: 'FAQPage', + pageScope: 'faq', + }), + }); + } + + return recommendationList.slice(0, PREVIEW_LIMIT); +} + +function ensureCurrentUser(currentUser) { + if (!currentUser || !currentUser.id) { + throw new ValidationError('auth.currentUserNotFound'); + } +} + +function buildRecommendationRows(siteId, crawlId, currentUser, recommendations) { + return recommendations.map((recommendation, index) => ({ + ...recommendation, + siteId, + crawlId, + importHash: `${siteId}:${crawlId}:${index}:${recommendation.schema_type || recommendation.title}`, + createdById: currentUser.id, + updatedById: currentUser.id, + })); +} + +function parseSummary(summary) { + if (!summary) { + return null; + } + + try { + return JSON.parse(summary); + } catch (error) { + return null; + } +} + +async function findOwnedSite(siteId, currentUser) { + ensureCurrentUser(currentUser); + + const site = await db.sites.findOne({ + where: { + id: siteId, + createdById: currentUser.id, + }, + }); + + if (!site) { + const error = new Error('Site analysis report not found.'); + error.code = 404; + throw error; + } + + return site; +} + +async function buildStoredReport(siteId, currentUser) { + const site = await findOwnedSite(siteId, currentUser); + const crawl = await db.site_crawls.findOne({ + where: { siteId: site.id }, + order: [['createdAt', 'DESC']], + }); + + const recommendations = crawl + ? await db.schema_recommendations.findAll({ + where: { + siteId: site.id, + crawlId: crawl.id, + }, + order: [['createdAt', 'ASC']], + }) + : []; + + return { + site: site.get({ plain: true }), + crawl: crawl ? crawl.get({ plain: true }) : null, + analysis: crawl ? parseSummary(crawl.summary) : null, + recommendations: recommendations.map((item) => item.get({ plain: true })), + }; +} + +function buildExportPayload({ site, analysis, recommendations }) { + const hostname = site?.base_url ? new URL(site.base_url).hostname : 'site'; + const exportableRecommendations = (recommendations || []).filter( + (recommendation) => recommendation.suggested_schema, + ); + + const sections = exportableRecommendations.map((recommendation) => { + return [ + `### ${recommendation.title}`, + `Priority: ${recommendation.priority || 'n/a'}`, + `Scope: ${recommendation.page_scope || 'n/a'}`, + recommendation.reason || '', + '', + recommendation.suggested_schema, + ] + .filter(Boolean) + .join('\n'); + }); + + const content = [ + `Schema recommendations for ${site?.name || hostname}`, + `Base URL: ${site?.base_url || ''}`, + analysis?.pageTitle ? `Analyzed page: ${analysis.pageTitle}` : '', + analysis?.fetchedAt ? `Analyzed at: ${analysis.fetchedAt}` : '', + '', + ...sections, + ] + .filter(Boolean) + .join('\n'); + + return { + filename: `${hostname.replace(/[^a-z0-9.-]/gi, '-')}-schema-recommendations.txt`, + contentType: 'text/plain; charset=utf-8', + content, + }; +} + +module.exports = class SitesService { + static async analyzeHomepage(data, currentUser) { + ensureCurrentUser(currentUser); + + const normalizedUrl = normalizeUrl(data?.url || data?.base_url); + const requestedName = + typeof data?.name === 'string' && data.name.trim() + ? data.name.trim() + : null; + const defaultName = new URL(normalizedUrl).hostname; + + const bootstrapTransaction = await db.sequelize.transaction(); + let site; + let crawl; + + try { + site = await db.sites.findOne({ + where: { + base_url: normalizedUrl, + createdById: currentUser.id, + }, + transaction: bootstrapTransaction, + }); + + if (!site) { + site = await db.sites.create( + { + name: requestedName || defaultName, + base_url: normalizedUrl, + crawl_status: 'running', + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { + transaction: bootstrapTransaction, + }, + ); + } else { + await site.update( + { + name: site.name || requestedName || defaultName, + crawl_status: 'running', + updatedById: currentUser.id, + }, + { + transaction: bootstrapTransaction, + }, + ); + } + + crawl = await db.site_crawls.create( + { + siteId: site.id, + status: 'running', + started_at: new Date(), + pages_scanned: 0, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { + transaction: bootstrapTransaction, + }, + ); + + await bootstrapTransaction.commit(); + } catch (error) { + await bootstrapTransaction.rollback(); + throw error; + } + + try { + const response = await axios.get(normalizedUrl, { + timeout: REQUEST_TIMEOUT, + maxRedirects: 5, + responseType: 'text', + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'User-Agent': + 'Mozilla/5.0 (compatible; SchemaCrawlerBot/1.0; +https://flatlogic.com)', + }, + }); + + const analyzedUrl = + response.request?.res?.responseUrl || response.config?.url || normalizedUrl; + const html = typeof response.data === 'string' ? response.data : ''; + const pageTitle = extractPageTitle(html); + const platform = detectPlatform(html, response.headers, analyzedUrl); + const schema = extractSchemaSummary(html); + const finishedAt = new Date(); + const recommendations = buildRecommendations({ + baseUrl: normalizedUrl, + siteName: requestedName || pageTitle || defaultName, + analysis: { + analyzedUrl, + pageTitle, + platform, + schema, + }, + html, + }); + const analysis = { + requestedUrl: normalizedUrl, + analyzedUrl, + pageTitle, + fetchedAt: finishedAt.toISOString(), + statusCode: response.status, + platform, + schema, + recommendationCount: recommendations.length, + }; + + const finalizeTransaction = await db.sequelize.transaction(); + let updatedSite; + let updatedCrawl; + let storedRecommendations = []; + + try { + updatedSite = await db.sites.findByPk(site.id, { + transaction: finalizeTransaction, + }); + updatedCrawl = await db.site_crawls.findByPk(crawl.id, { + transaction: finalizeTransaction, + }); + + await updatedSite.update( + { + name: updatedSite.name || requestedName || pageTitle || defaultName, + detected_platform: platform.detected, + crawl_status: 'completed', + last_crawled_at: finishedAt, + updatedById: currentUser.id, + }, + { + transaction: finalizeTransaction, + }, + ); + + await updatedCrawl.update( + { + status: 'completed', + finished_at: finishedAt, + pages_scanned: 1, + summary: JSON.stringify(analysis), + updatedById: currentUser.id, + }, + { + transaction: finalizeTransaction, + }, + ); + + if (recommendations.length > 0) { + storedRecommendations = await db.schema_recommendations.bulkCreate( + buildRecommendationRows( + updatedSite.id, + updatedCrawl.id, + currentUser, + recommendations, + ), + { + transaction: finalizeTransaction, + }, + ); + } + + await finalizeTransaction.commit(); + } catch (error) { + await finalizeTransaction.rollback(); + throw error; + } + + return { + site: updatedSite.get({ plain: true }), + crawl: updatedCrawl.get({ plain: true }), + analysis, + recommendations: storedRecommendations.map((item) => item.get({ plain: true })), + }; + } catch (error) { + console.error('Site analysis failed:', error); + + const failureAnalysis = buildFailureAnalysis(normalizedUrl, error); + const failedAt = new Date(); + const failureTransaction = await db.sequelize.transaction(); + let failedSite; + let failedCrawl; + + try { + failedSite = await db.sites.findByPk(site.id, { + transaction: failureTransaction, + }); + failedCrawl = await db.site_crawls.findByPk(crawl.id, { + transaction: failureTransaction, + }); + + await failedSite.update( + { + name: failedSite.name || requestedName || defaultName, + crawl_status: 'failed', + updatedById: currentUser.id, + }, + { + transaction: failureTransaction, + }, + ); + + await failedCrawl.update( + { + status: 'failed', + finished_at: failedAt, + pages_scanned: 0, + summary: JSON.stringify(failureAnalysis), + updatedById: currentUser.id, + }, + { + transaction: failureTransaction, + }, + ); + + await failureTransaction.commit(); + } catch (updateError) { + await failureTransaction.rollback(); + console.error('Failed to persist crawl failure state:', updateError); + throw updateError; + } + + if (error instanceof ValidationError || axios.isAxiosError(error)) { + return { + site: failedSite.get({ plain: true }), + crawl: failedCrawl.get({ plain: true }), + analysis: failureAnalysis, + recommendations: [], + error: failureAnalysis.error, + }; + } + + throw error; + } + } + + static async getLatestReport(siteId, currentUser) { + return buildStoredReport(siteId, currentUser); + } + + static async exportCode(data, currentUser) { + ensureCurrentUser(currentUser); + + const { recommendationId, siteId } = data || {}; + + if (recommendationId) { + const recommendation = await db.schema_recommendations.findOne({ + where: { + id: recommendationId, + createdById: currentUser.id, + }, + include: [ + { + model: db.sites, + as: 'site', + }, + ], + }); + + if (!recommendation) { + const error = new Error('Recommendation not found.'); + error.code = 404; + throw error; + } + + const plain = recommendation.get({ plain: true }); + const hostname = new URL(plain.site.base_url).hostname; + return { + filename: `${hostname.replace(/[^a-z0-9.-]/gi, '-')}-${(plain.schema_type || 'schema').toLowerCase()}.jsonld`, + contentType: 'application/ld+json; charset=utf-8', + content: plain.suggested_schema || '', + }; + } + + if (!siteId) { + throw new ValidationError('errors.validation.message'); + } + + const report = await buildStoredReport(siteId, currentUser); + return buildExportPayload(report); + } + + static async emailCode(data, currentUser) { + ensureCurrentUser(currentUser); + + if (!EmailSender.isConfigured) { + const error = new Error('Email delivery is not configured for this environment yet.'); + error.code = 400; + throw error; + } + + const to = typeof data?.to === 'string' ? data.to.trim() : ''; + const recommendationId = data?.recommendationId || null; + const siteId = data?.siteId || null; + + if (!to) { + throw new ValidationError('errors.validation.message'); + } + + let subject = 'Schema recommendations'; + let html = '

No schema recommendations were available.

'; + + if (recommendationId) { + const recommendation = await db.schema_recommendations.findOne({ + where: { + id: recommendationId, + createdById: currentUser.id, + }, + include: [ + { + model: db.sites, + as: 'site', + }, + ], + }); + + if (!recommendation) { + const error = new Error('Recommendation not found.'); + error.code = 404; + throw error; + } + + const plain = recommendation.get({ plain: true }); + subject = `${plain.title} — schema recommendation`; + html = [ + `

Schema recommendation for ${escapeHtml(plain.site.name || plain.site.base_url)}

`, + `

Priority: ${escapeHtml(plain.priority || 'n/a')}

`, + `

Scope: ${escapeHtml(plain.page_scope || 'n/a')}

`, + `

${escapeHtml(plain.reason || '')}

`, + plain.suggested_schema + ? `
${escapeHtml(plain.suggested_schema)}
` + : '

No code snippet was generated for this recommendation.

', + ].join(''); + } else if (siteId) { + const report = await buildStoredReport(siteId, currentUser); + subject = `${report.site.name || report.site.base_url} — schema recommendations`; + html = [ + `

Attached below are the latest schema recommendations for ${escapeHtml(report.site.name || report.site.base_url)}.

`, + ...(report.recommendations || []).map((recommendation) => { + return [ + `

${escapeHtml(recommendation.title)}

`, + `

Priority: ${escapeHtml(recommendation.priority || 'n/a')}

`, + `

Scope: ${escapeHtml(recommendation.page_scope || 'n/a')}

`, + `

${escapeHtml(recommendation.reason || '')}

`, + recommendation.suggested_schema + ? `
${escapeHtml(recommendation.suggested_schema)}
` + : '

No code snippet was generated for this recommendation.

', + ].join(''); + }), + ].join(''); + } else { + throw new ValidationError('errors.validation.message'); + } + + await new EmailSender({ + to, + subject, + html: async () => html, + }).send(); + + return { + success: true, + to, + subject, + }; + } +}; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 279b53d..0f76b72 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -96,6 +96,13 @@ const menuAside: MenuAsideItem[] = [ icon: 'mdiClipboardTextClock' in icon ? icon['mdiClipboardTextClock' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, permissions: 'READ_AUDIT_LOGS' }, + { + href: '/sites/analyzer', + label: 'Schema Analyzer', + icon: icon.mdiChartTimelineVariant, + permissions: 'READ_SITES' + }, + { href: '/profile', label: 'Profile', diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 5b00f29..16ed38c 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -6,6 +6,7 @@ import type { ReactElement } from 'react' import LayoutAuthenticated from '../layouts/Authenticated' import SectionMain from '../components/SectionMain' import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' +import BaseButton from '../components/BaseButton' import BaseIcon from "../components/BaseIcon"; import { getPageTitle } from '../config' import Link from "next/link"; @@ -101,6 +102,102 @@ const Dashboard = () => { main> {''} + +
+
+
+
+ + AI Engineer quick-start +
+

Guide the team through the first real delivery loop.

+

+ Use the generated entities as one connected workflow: start with a project, collect feature requests, and move decisions into conversations. +

+
+ + +
+
+ +
+ {hasPermission(currentUser, 'READ_PROJECTS') && ( +
+
+
+

Step 1

+

Projects

+
+ +
+

Create the container for what the team is shipping next.

+
+ Current count + {projects} +
+
+ )} + + {hasPermission(currentUser, 'READ_FEATURE_REQUESTS') && ( +
+
+
+

Step 2

+

Feature requests

+
+ +
+

Capture requests and make priorities visible early.

+
+ Current count + {feature_requests} +
+
+ )} + + {hasPermission(currentUser, 'READ_CONVERSATIONS') && ( +
+
+
+

Step 3

+

Conversations

+
+ +
+

Keep decisions connected to the work as the scope sharpens.

+
+ Current count + {conversations} +
+
+ )} +
+
+
{hasPermission(currentUser, 'CREATE_ROLES') && state.style.linkColor); + const { currentUser } = useAppSelector((state) => state.auth); - const title = 'App Preview' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; + const adminHref = currentUser?.id ? '/dashboard' : '/login'; + const adminLabel = currentUser?.id ? 'Open admin interface' : 'Login to admin'; + const welcomeLabel = currentUser?.firstName + ? `Welcome back, ${currentUser.firstName}` + : 'Your AI delivery launchpad'; return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('AI Engineer Launchpad')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - - +
+
+
+
+
+
+
+
- - +
+
+ + + + + AI Engineer + + +
+ + +
+
+ +
+
+
+
+ + {welcomeLabel} +
+

+ Turn the generated app into a real shipping workflow. +

+

+ This first slice turns your seed project into a polished launchpad for planning work, + collecting feature requests, and keeping product conversations moving inside the admin. +

+ +
+ + +
+ +
+
+
3
+
Core steps to get the first product loop running
+
+
+
0
+
New backend models required for this first delivery
+
+
+
100%
+
Built on top of the CRUD and auth you already have
+
+
+
+ +
+
+
+
+
+

First delivery

+

Plan → Request → Deliver

+
+
+ +
+
+ +
+ {workflowModules.map((module, index) => ( +
+
+
+ +
+
+
+

{module.title}

+ + Step {index + 1} + +
+

{module.description}

+
+
+
+ ))} +
+ +
+ Immediate value: a clearer public homepage and a guided admin workflow without changing your data model. +
+
+
+
+
+ +
+
+

Workflow map

+

Everything starts with one clean loop.

+

+ Reuse the generated entities, but present them as a focused product delivery flow instead of a loose set of tables. +

+
+ +
+ {workflowModules.map((module) => ( + +
+
+ +
+

{module.title}

+

{module.description}

+
+ +
+
+
+ ))} +
+
+ +
+
+

First-run guide

+

A better starting experience for the team.

+
+ +
+ {launchSteps.map((item) => ( +
+
{item.step}
+

{item.title}

+

{item.description}

+
+ ))} +
+
+ +
+
+
+
+

Ready to explore?

+

Jump into the admin and ship the first real workflow.

+

+ The public page now tells the story clearly, and the admin now has a more intentional path for first actions. +

+
+
+ + +
+
+
+
+
+ +
+
+

© 2026 AI Engineer. The first iteration is live and ready for feedback.

+
+ Privacy Policy + Terms of Use + Login + Admin interface +
+
+
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
- -
+ ); } Starter.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/sites/analyzer.tsx b/frontend/src/pages/sites/analyzer.tsx new file mode 100644 index 0000000..0055a43 --- /dev/null +++ b/frontend/src/pages/sites/analyzer.tsx @@ -0,0 +1,522 @@ +import * as icon from '@mdi/js'; +import Head from 'next/head'; +import axios from 'axios'; +import React, { ReactElement } from 'react'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../../components/BaseButton'; +import BaseButtons from '../../components/BaseButtons'; +import CardBox from '../../components/CardBox'; +import FormField from '../../components/FormField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import { useAppSelector } from '../../stores/hooks'; + +type AnalysisPayload = { + requestedUrl?: string; + analyzedUrl?: string; + pageTitle?: string | null; + fetchedAt?: string; + statusCode?: number; + platform?: { + detected?: string; + label?: string; + matchedSignals?: string[]; + }; + schema?: { + hasStructuredData?: boolean; + jsonLd?: { + count?: number; + types?: string[]; + invalidBlocks?: { index: number; message: string }[]; + }; + microdata?: { + count?: number; + detected?: boolean; + }; + rdfa?: { + count?: number; + detected?: boolean; + }; + }; + error?: string; +}; + +type Recommendation = { + id: string; + title: string; + recommendation_type?: string; + schema_type?: string; + page_scope?: string; + priority?: string; + reason?: string; + expected_impact?: string; + suggested_schema?: string | null; +}; + +type ReportResponse = { + site?: { + id: string; + name?: string; + base_url?: string; + detected_platform?: string; + crawl_status?: string; + }; + crawl?: { + id: string; + status?: string; + }; + analysis?: AnalysisPayload | null; + recommendations?: Recommendation[]; + error?: string; +}; + +const initialReport: ReportResponse | null = null; + +const SchemaAnalyzerPage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const [url, setUrl] = React.useState(''); + const [emailTo, setEmailTo] = React.useState(currentUser?.email || ''); + const [report, setReport] = React.useState(initialReport); + const [isAnalyzing, setIsAnalyzing] = React.useState(false); + const [isExportingAll, setIsExportingAll] = React.useState(false); + const [emailingId, setEmailingId] = React.useState(null); + const [exportingId, setExportingId] = React.useState(null); + + React.useEffect(() => { + if (currentUser?.email) { + setEmailTo(currentUser.email); + } + }, [currentUser?.email]); + + const notify = React.useCallback((type: 'success' | 'error' | 'info', message: string) => { + toast(message, { type, position: 'bottom-center' }); + }, []); + + const recommendations = report?.recommendations || []; + const exportableRecommendations = recommendations.filter( + (recommendation) => recommendation.suggested_schema, + ); + + const handleAnalyze = async () => { + if (!url.trim()) { + notify('error', 'Enter a website URL first.'); + return; + } + + try { + setIsAnalyzing(true); + const response = await axios.post('/sites/analyze', { + url: url.trim(), + }); + setReport(response.data); + + if (response.data.error) { + notify('error', response.data.error); + } else { + notify('success', 'Site analyzed successfully.'); + } + } catch (error: any) { + console.error('Schema analyze failed:', error); + notify('error', error?.response?.data || 'Failed to analyze the site.'); + } finally { + setIsAnalyzing(false); + } + }; + + const handleCopyCode = async (recommendation: Recommendation) => { + if (!recommendation.suggested_schema) { + notify('info', 'This recommendation does not include code yet.'); + return; + } + + try { + await navigator.clipboard.writeText(recommendation.suggested_schema); + notify('success', 'Schema code copied to clipboard.'); + } catch (error) { + console.error('Copy schema failed:', error); + notify('error', 'Unable to copy code in this browser.'); + } + }; + + const downloadBlob = (blob: Blob, filename: string) => { + const blobUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(blobUrl); + }; + + const parseFilename = (contentDisposition?: string) => { + const match = contentDisposition?.match(/filename="?([^\";]+)"?/i); + return match?.[1] || 'schema-export.txt'; + }; + + const extractBlobError = async (error: any) => { + if (error?.response?.data instanceof Blob) { + return error.response.data.text(); + } + + return error?.response?.data || 'Request failed.'; + }; + + const handleExportRecommendation = async (recommendation: Recommendation) => { + try { + setExportingId(recommendation.id); + const response = await axios.post('/sites/export', { + recommendationId: recommendation.id, + }, { + responseType: 'blob', + }); + const filename = parseFilename(response.headers['content-disposition']); + downloadBlob(response.data, filename); + notify('success', 'Recommendation exported.'); + } catch (error: any) { + console.error('Export recommendation failed:', error); + notify('error', await extractBlobError(error)); + } finally { + setExportingId(null); + } + }; + + const handleExportAll = async () => { + if (!report?.site?.id) { + notify('error', 'Analyze a site first.'); + return; + } + + try { + setIsExportingAll(true); + const response = await axios.post('/sites/export', { + siteId: report.site.id, + }, { + responseType: 'blob', + }); + const filename = parseFilename(response.headers['content-disposition']); + downloadBlob(response.data, filename); + notify('success', 'Full recommendation export downloaded.'); + } catch (error: any) { + console.error('Export all failed:', error); + notify('error', await extractBlobError(error)); + } finally { + setIsExportingAll(false); + } + }; + + const handleEmailCode = async (recommendationId?: string) => { + if (!report?.site?.id) { + notify('error', 'Analyze a site first.'); + return; + } + + if (!emailTo.trim()) { + notify('error', 'Add a recipient email first.'); + return; + } + + try { + setEmailingId(recommendationId || 'all'); + await axios.post('/sites/email-code', recommendationId + ? { + recommendationId, + to: emailTo.trim(), + } + : { + siteId: report.site.id, + to: emailTo.trim(), + }); + notify('success', recommendationId ? 'Schema code emailed.' : 'Full recommendation report emailed.'); + } catch (error: any) { + console.error('Email schema failed:', error); + notify('error', error?.response?.data || 'Failed to send email.'); + } finally { + setEmailingId(null); + } + }; + + return ( + <> + + {getPageTitle('Schema Analyzer')} + + + + {''} + + + +
+
+

Analyze a customer site

+

+ Enter a domain or full URL. The app will detect the platform, inspect homepage structured data, + generate rules-based schema recommendations, and prepare developer-ready code snippets. +

+ +
+ + setUrl(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleAnalyze().catch(() => null); + } + }} + /> + +
+ + + { + handleAnalyze().catch(() => null); + }} + /> + { + const combined = exportableRecommendations + .map((recommendation) => recommendation.suggested_schema) + .filter(Boolean) + .join('\n\n'); + navigator.clipboard + .writeText(combined) + .then(() => notify('success', 'All schema code copied to clipboard.')) + .catch((error) => { + console.error('Copy all code failed:', error); + notify('error', 'Unable to copy the combined code.'); + }); + }} + /> + +
+ +
+

Delivery actions

+

+ Export a developer handoff file or email the latest recommendations directly. +

+
+ + setEmailTo(event.target.value)} + /> + +
+ + { + handleExportAll().catch(() => null); + }} + /> + { + handleEmailCode().catch(() => null); + }} + /> + +
+
+
+ + {report?.analysis && ( +
+ +

Analysis snapshot

+
+
+
Platform
+
+ {report.analysis.platform?.label || 'Unknown'} +
+
+ {report.analysis.platform?.matchedSignals?.join(', ') || 'No strong platform signals found.'} +
+
+
+
Structured data
+
+ {report.analysis.schema?.hasStructuredData ? 'Detected' : 'Not detected'} +
+
+ JSON-LD: {report.analysis.schema?.jsonLd?.count || 0} • Microdata: {report.analysis.schema?.microdata?.count || 0} • RDFa: {report.analysis.schema?.rdfa?.count || 0} +
+
+
+ +
+
+ Requested URL:{' '} + {report.analysis.requestedUrl || '—'} +
+
+ Analyzed URL:{' '} + {report.analysis.analyzedUrl || '—'} +
+
+ Page title:{' '} + {report.analysis.pageTitle || '—'} +
+
+ JSON-LD types found:{' '} + {(report.analysis.schema?.jsonLd?.types || []).join(', ') || 'None'} +
+ {report.analysis.error && ( +
+ {report.analysis.error} +
+ )} +
+
+ + +
+
+

Recommendations

+

+ Prioritized next actions with ready-to-copy schema where possible. +

+
+
+ {recommendations.length} recommendation{recommendations.length === 1 ? '' : 's'} +
+
+ +
+ {recommendations.length === 0 && ( +
+ No recommendations were generated for this page yet. +
+ )} + + {recommendations.map((recommendation) => ( +
+
+
+
+ + {recommendation.priority || 'priority'} + + {recommendation.schema_type && ( + + {recommendation.schema_type} + + )} + {recommendation.page_scope && ( + + {recommendation.page_scope} + + )} +
+

+ {recommendation.title} +

+

+ {recommendation.reason} +

+ {recommendation.expected_impact && ( +

+ Expected impact:{' '} + {recommendation.expected_impact} +

+ )} +
+ + + { + handleCopyCode(recommendation).catch(() => null); + }} + /> + { + handleExportRecommendation(recommendation).catch(() => null); + }} + /> + { + handleEmailCode(recommendation.id).catch(() => null); + }} + /> + +
+ +
+
Suggested code
+
+                        {recommendation.suggested_schema || 'No code snippet generated for this recommendation.'}
+                      
+
+
+ ))} +
+
+
+ )} + + +
+ + ); +}; + +SchemaAnalyzerPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default SchemaAnalyzerPage;