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(/