Autosave: 20260414-153854
This commit is contained in:
parent
9c9223a7b3
commit
b9a2103bb3
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
84
backend/src/db/models/schema_recommendations.js
Normal file
84
backend/src/db/models/schema_recommendations.js
Normal file
@ -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;
|
||||
};
|
||||
73
backend/src/db/models/site_crawls.js
Normal file
73
backend/src/db/models/site_crawls.js
Normal file
@ -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;
|
||||
};
|
||||
72
backend/src/db/models/sites.js
Normal file
72
backend/src/db/models/sites.js
Normal file
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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 }),
|
||||
|
||||
69
backend/src/routes/sites.js
Normal file
69
backend/src/routes/sites.js
Normal file
@ -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;
|
||||
1090
backend/src/services/sites.js
Normal file
1090
backend/src/services/sites.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className='mb-8 overflow-hidden rounded-3xl border border-slate-200 bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 text-white shadow-2xl shadow-slate-950/10'>
|
||||
<div className='grid gap-8 p-6 lg:grid-cols-[1.1fr,0.9fr] lg:p-8'>
|
||||
<div>
|
||||
<div className='inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-sky-100'>
|
||||
<span className='inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400' />
|
||||
AI Engineer quick-start
|
||||
</div>
|
||||
<h2 className='mt-4 text-3xl font-bold tracking-tight text-white'>Guide the team through the first real delivery loop.</h2>
|
||||
<p className='mt-4 max-w-2xl text-sm leading-7 text-slate-300'>
|
||||
Use the generated entities as one connected workflow: start with a project, collect feature requests, and move decisions into conversations.
|
||||
</p>
|
||||
<div className='mt-6 flex flex-col gap-3 sm:flex-row'>
|
||||
<BaseButton href='/projects/projects-list' label='Open projects' color='info' className='justify-center shadow-lg shadow-blue-900/30 sm:min-w-44' />
|
||||
<BaseButton href='/feature_requests/feature_requests-list' label='Review requests' color='white' outline className='justify-center border-white/15 bg-white/5 text-white hover:border-white/30 hover:bg-white/10 sm:min-w-44' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-3 lg:grid-cols-1'>
|
||||
{hasPermission(currentUser, 'READ_PROJECTS') && (
|
||||
<div className='rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-sky-300'>Step 1</p>
|
||||
<h3 className='mt-2 text-lg font-semibold text-white'>Projects</h3>
|
||||
</div>
|
||||
<BaseIcon
|
||||
className='text-sky-300'
|
||||
w='w-12'
|
||||
h='h-12'
|
||||
size={24}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
path={'mdiBriefcase' in icon ? icon['mdiBriefcase' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||
/>
|
||||
</div>
|
||||
<p className='mt-3 text-sm leading-6 text-slate-300'>Create the container for what the team is shipping next.</p>
|
||||
<div className='mt-4 flex items-center justify-between text-sm'>
|
||||
<span className='text-slate-400'>Current count</span>
|
||||
<span className='font-semibold text-white'>{projects}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPermission(currentUser, 'READ_FEATURE_REQUESTS') && (
|
||||
<div className='rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-fuchsia-300'>Step 2</p>
|
||||
<h3 className='mt-2 text-lg font-semibold text-white'>Feature requests</h3>
|
||||
</div>
|
||||
<BaseIcon
|
||||
className='text-fuchsia-300'
|
||||
w='w-12'
|
||||
h='h-12'
|
||||
size={24}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
path={'mdiLightbulbOn' in icon ? icon['mdiLightbulbOn' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||
/>
|
||||
</div>
|
||||
<p className='mt-3 text-sm leading-6 text-slate-300'>Capture requests and make priorities visible early.</p>
|
||||
<div className='mt-4 flex items-center justify-between text-sm'>
|
||||
<span className='text-slate-400'>Current count</span>
|
||||
<span className='font-semibold text-white'>{feature_requests}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPermission(currentUser, 'READ_CONVERSATIONS') && (
|
||||
<div className='rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-emerald-300'>Step 3</p>
|
||||
<h3 className='mt-2 text-lg font-semibold text-white'>Conversations</h3>
|
||||
</div>
|
||||
<BaseIcon
|
||||
className='text-emerald-300'
|
||||
w='w-12'
|
||||
h='h-12'
|
||||
size={24}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
path={'mdiChat' in icon ? icon['mdiChat' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||
/>
|
||||
</div>
|
||||
<p className='mt-3 text-sm leading-6 text-slate-300'>Keep decisions connected to the work as the scope sharpens.</p>
|
||||
<div className='mt-4 flex items-center justify-between text-sm'>
|
||||
<span className='text-slate-400'>Current count</span>
|
||||
<span className='font-semibold text-white'>{conversations}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
|
||||
currentUser={currentUser}
|
||||
|
||||
@ -1,166 +1,259 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import * as icon from '@mdi/js';
|
||||
import React from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
|
||||
const workflowModules = [
|
||||
{
|
||||
title: 'Projects',
|
||||
description: 'Frame the work, assign ownership, and give every initiative a home base.',
|
||||
icon: icon.mdiBriefcase,
|
||||
href: '/projects/projects-list',
|
||||
accent: 'from-violet-500 to-fuchsia-500',
|
||||
},
|
||||
{
|
||||
title: 'Feature requests',
|
||||
description: 'Capture incoming ideas and turn them into a visible queue of decisions.',
|
||||
icon: icon.mdiLightbulbOn,
|
||||
href: '/feature_requests/feature_requests-list',
|
||||
accent: 'from-sky-500 to-cyan-500',
|
||||
},
|
||||
{
|
||||
title: 'Conversations',
|
||||
description: 'Keep product and engineering context together as the team refines the scope.',
|
||||
icon: icon.mdiChat,
|
||||
href: '/conversations/conversations-list',
|
||||
accent: 'from-emerald-500 to-teal-500',
|
||||
},
|
||||
];
|
||||
|
||||
const launchSteps = [
|
||||
{
|
||||
step: '01',
|
||||
title: 'Open the admin workspace',
|
||||
description: 'Sign in to the app and land directly in the overview with your existing data and widgets.',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
title: 'Create or review a project',
|
||||
description: 'Use Projects as the top-level container for the first thing your team is shipping.',
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
title: 'Capture requests and align in conversation',
|
||||
description: 'Track ideas in Feature requests and move the discussion forward in Conversations and Messages.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('image');
|
||||
const [contentPosition, setContentPosition] = useState('left');
|
||||
const textColor = useAppSelector((state) => 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) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
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 (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('AI Engineer Launchpad')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your App Preview app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
<div className='min-h-screen bg-slate-950 text-white'>
|
||||
<div className='absolute inset-x-0 top-0 -z-0 overflow-hidden'>
|
||||
<div className='mx-auto h-[34rem] max-w-7xl'>
|
||||
<div className='absolute left-1/2 top-[-6rem] h-72 w-72 -translate-x-[18rem] rounded-full bg-fuchsia-500/25 blur-3xl' />
|
||||
<div className='absolute left-1/2 top-24 h-80 w-80 translate-x-8 rounded-full bg-sky-500/20 blur-3xl' />
|
||||
<div className='absolute right-10 top-48 h-64 w-64 rounded-full bg-emerald-400/15 blur-3xl' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
<div className='relative z-10'>
|
||||
<header className='mx-auto flex w-full max-w-7xl items-center justify-between px-6 py-6 lg:px-10'>
|
||||
<Link href='/' className='flex items-center gap-3 text-sm font-semibold tracking-[0.24em] text-white/90 uppercase'>
|
||||
<span className='flex h-11 w-11 items-center justify-center rounded-2xl border border-white/15 bg-white/10 shadow-lg shadow-sky-500/10 backdrop-blur'>
|
||||
<BaseIcon path={icon.mdiRobotExcited} size={22} className='text-sky-300' />
|
||||
</span>
|
||||
<span>AI Engineer</span>
|
||||
</Link>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<BaseButton href='/login' label='Login' color='white' outline className='border-white/20 bg-white/5 text-white hover:border-white/40 hover:bg-white/10' />
|
||||
<BaseButton href='/dashboard' label='Admin interface' color='info' className='shadow-lg shadow-blue-900/30' />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section className='mx-auto grid w-full max-w-7xl gap-12 px-6 pb-14 pt-8 lg:grid-cols-[1.1fr,0.9fr] lg:px-10 lg:pb-24 lg:pt-12'>
|
||||
<div className='flex flex-col justify-center'>
|
||||
<div className='mb-5 inline-flex w-fit items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-sky-100 backdrop-blur'>
|
||||
<span className='inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400' />
|
||||
{welcomeLabel}
|
||||
</div>
|
||||
<h1 className='max-w-3xl text-5xl font-black leading-tight tracking-tight text-white sm:text-6xl'>
|
||||
Turn the generated app into a real shipping workflow.
|
||||
</h1>
|
||||
<p className='mt-6 max-w-2xl text-lg leading-8 text-slate-300'>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className='mt-8 flex flex-col gap-3 sm:flex-row'>
|
||||
<BaseButton href={adminHref} label={adminLabel} color='info' className='justify-center shadow-lg shadow-blue-900/30 sm:min-w-56' />
|
||||
<BaseButton href='#workflow' label='See the workflow' color='white' outline className='justify-center border-white/20 bg-white/5 text-white hover:border-white/40 hover:bg-white/10 sm:min-w-48' />
|
||||
</div>
|
||||
|
||||
<div className='mt-10 grid gap-4 sm:grid-cols-3'>
|
||||
<div className='rounded-2xl border border-white/10 bg-white/5 p-4 backdrop-blur'>
|
||||
<div className='text-3xl font-bold text-white'>3</div>
|
||||
<div className='mt-1 text-sm text-slate-300'>Core steps to get the first product loop running</div>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-white/10 bg-white/5 p-4 backdrop-blur'>
|
||||
<div className='text-3xl font-bold text-white'>0</div>
|
||||
<div className='mt-1 text-sm text-slate-300'>New backend models required for this first delivery</div>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-white/10 bg-white/5 p-4 backdrop-blur'>
|
||||
<div className='text-3xl font-bold text-white'>100%</div>
|
||||
<div className='mt-1 text-sm text-slate-300'>Built on top of the CRUD and auth you already have</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative flex items-center justify-center'>
|
||||
<div className='w-full max-w-xl rounded-[2rem] border border-white/10 bg-white/10 p-5 shadow-2xl shadow-slate-950/40 backdrop-blur-xl'>
|
||||
<div className='rounded-[1.75rem] border border-white/10 bg-slate-950/75 p-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.28em] text-sky-300'>First delivery</p>
|
||||
<h2 className='mt-3 text-2xl font-bold text-white'>Plan → Request → Deliver</h2>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-white/10 bg-white/5 p-3'>
|
||||
<BaseIcon path={icon.mdiOrbit} size={28} className='text-fuchsia-300' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 space-y-4'>
|
||||
{workflowModules.map((module, index) => (
|
||||
<div key={module.title} className='rounded-2xl border border-white/10 bg-white/[0.04] p-4'>
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br ${module.accent}`}>
|
||||
<BaseIcon path={module.icon} size={22} className='text-white' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<h3 className='text-lg font-semibold text-white'>{module.title}</h3>
|
||||
<span className='rounded-full border border-white/10 px-2.5 py-1 text-xs font-medium text-slate-300'>
|
||||
Step {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<p className='mt-1 text-sm leading-6 text-slate-300'>{module.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='mt-6 rounded-2xl border border-emerald-400/20 bg-emerald-400/10 p-4 text-sm text-emerald-100'>
|
||||
Immediate value: a clearer public homepage and a guided admin workflow without changing your data model.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id='workflow' className='mx-auto w-full max-w-7xl px-6 py-8 lg:px-10 lg:py-14'>
|
||||
<div className='mb-8 max-w-2xl'>
|
||||
<p className='text-sm font-semibold uppercase tracking-[0.28em] text-sky-300'>Workflow map</p>
|
||||
<h2 className='mt-4 text-3xl font-bold tracking-tight text-white sm:text-4xl'>Everything starts with one clean loop.</h2>
|
||||
<p className='mt-4 text-base leading-7 text-slate-300'>
|
||||
Reuse the generated entities, but present them as a focused product delivery flow instead of a loose set of tables.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-3'>
|
||||
{workflowModules.map((module) => (
|
||||
<CardBox key={module.title} className='h-full border-white/10 bg-white/95 shadow-xl shadow-slate-950/10'>
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className={`mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br ${module.accent}`}>
|
||||
<BaseIcon path={module.icon} size={24} className='text-white' />
|
||||
</div>
|
||||
<h3 className='text-2xl font-bold text-slate-950'>{module.title}</h3>
|
||||
<p className='mt-3 flex-1 text-sm leading-7 text-slate-600'>{module.description}</p>
|
||||
<div className='mt-6'>
|
||||
<BaseButton
|
||||
href={currentUser?.id ? module.href : '/login'}
|
||||
label={currentUser?.id ? `Open ${module.title}` : 'Login to access'}
|
||||
color='info'
|
||||
className='w-full justify-center'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='mx-auto w-full max-w-7xl px-6 py-8 lg:px-10 lg:py-14'>
|
||||
<div className='mb-8 max-w-2xl'>
|
||||
<p className='text-sm font-semibold uppercase tracking-[0.28em] text-fuchsia-300'>First-run guide</p>
|
||||
<h2 className='mt-4 text-3xl font-bold tracking-tight text-white sm:text-4xl'>A better starting experience for the team.</h2>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-3'>
|
||||
{launchSteps.map((item) => (
|
||||
<div key={item.step} className='rounded-[1.75rem] border border-white/10 bg-white/5 p-6 backdrop-blur'>
|
||||
<div className='text-sm font-semibold uppercase tracking-[0.28em] text-sky-300'>{item.step}</div>
|
||||
<h3 className='mt-4 text-xl font-bold text-white'>{item.title}</h3>
|
||||
<p className='mt-3 text-sm leading-7 text-slate-300'>{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='mx-auto w-full max-w-7xl px-6 pb-20 pt-8 lg:px-10'>
|
||||
<div className='rounded-[2rem] border border-white/10 bg-gradient-to-r from-sky-500/20 via-blue-500/15 to-fuchsia-500/20 p-8 shadow-2xl shadow-slate-950/20 backdrop-blur'>
|
||||
<div className='flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div className='max-w-2xl'>
|
||||
<p className='text-sm font-semibold uppercase tracking-[0.28em] text-sky-200'>Ready to explore?</p>
|
||||
<h2 className='mt-3 text-3xl font-bold text-white'>Jump into the admin and ship the first real workflow.</h2>
|
||||
<p className='mt-3 text-base leading-7 text-slate-200'>
|
||||
The public page now tells the story clearly, and the admin now has a more intentional path for first actions.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col gap-3 sm:flex-row'>
|
||||
<BaseButton href='/login' label='Login' color='white' outline className='justify-center border-white/20 bg-white/5 text-white hover:border-white/40 hover:bg-white/10 sm:min-w-40' />
|
||||
<BaseButton href='/dashboard' label='Admin interface' color='info' className='justify-center sm:min-w-48' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className='border-t border-white/10 bg-slate-950/70'>
|
||||
<div className='mx-auto flex w-full max-w-7xl flex-col gap-4 px-6 py-6 text-sm text-slate-400 lg:flex-row lg:items-center lg:justify-between lg:px-10'>
|
||||
<p>© 2026 AI Engineer. The first iteration is live and ready for feedback.</p>
|
||||
<div className='flex flex-wrap items-center gap-4'>
|
||||
<Link href='/privacy-policy' className='transition hover:text-white'>Privacy Policy</Link>
|
||||
<Link href='/terms-of-use' className='transition hover:text-white'>Terms of Use</Link>
|
||||
<Link href='/login' className='transition hover:text-white'>Login</Link>
|
||||
<Link href='/dashboard' className='transition hover:text-white'>Admin interface</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
522
frontend/src/pages/sites/analyzer.tsx
Normal file
522
frontend/src/pages/sites/analyzer.tsx
Normal file
@ -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<ReportResponse | null>(initialReport);
|
||||
const [isAnalyzing, setIsAnalyzing] = React.useState(false);
|
||||
const [isExportingAll, setIsExportingAll] = React.useState(false);
|
||||
const [emailingId, setEmailingId] = React.useState<string | null>(null);
|
||||
const [exportingId, setExportingId] = React.useState<string | null>(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<ReportResponse>('/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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Schema Analyzer')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={icon.mdiChartTimelineVariant}
|
||||
title='Schema Analyzer'
|
||||
main
|
||||
>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<CardBox className='mb-6'>
|
||||
<div className='grid gap-6 lg:grid-cols-[1.2fr,0.8fr]'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold text-slate-900 dark:text-white'>Analyze a customer site</h2>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className='mt-6'>
|
||||
<FormField
|
||||
label='Website URL'
|
||||
labelFor='schema-site-url'
|
||||
help='Examples: example.com or https://www.example.com'
|
||||
>
|
||||
<input
|
||||
id='schema-site-url'
|
||||
name='schema-site-url'
|
||||
placeholder='https://example.com'
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleAnalyze().catch(() => null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<BaseButtons type='justify-start' className='mt-2'>
|
||||
<BaseButton
|
||||
color='info'
|
||||
icon={icon.mdiMagnify}
|
||||
label={isAnalyzing ? 'Analyzing…' : 'Analyze site'}
|
||||
disabled={isAnalyzing}
|
||||
onClick={() => {
|
||||
handleAnalyze().catch(() => null);
|
||||
}}
|
||||
/>
|
||||
<BaseButton
|
||||
color='whiteDark'
|
||||
outline
|
||||
icon={icon.mdiContentCopy}
|
||||
label='Copy all code'
|
||||
disabled={exportableRecommendations.length === 0}
|
||||
onClick={() => {
|
||||
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.');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</BaseButtons>
|
||||
</div>
|
||||
|
||||
<div className='rounded-2xl border border-slate-200 bg-slate-50 p-5 dark:border-slate-700 dark:bg-slate-900/40'>
|
||||
<h3 className='text-base font-semibold text-slate-900 dark:text-white'>Delivery actions</h3>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
||||
Export a developer handoff file or email the latest recommendations directly.
|
||||
</p>
|
||||
<div className='mt-4'>
|
||||
<FormField label='Developer email' labelFor='schema-email-recipient'>
|
||||
<input
|
||||
id='schema-email-recipient'
|
||||
name='schema-email-recipient'
|
||||
placeholder='developer@example.com'
|
||||
value={emailTo}
|
||||
onChange={(event) => setEmailTo(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<BaseButtons type='justify-start'>
|
||||
<BaseButton
|
||||
color='success'
|
||||
icon={icon.mdiDownload}
|
||||
label={isExportingAll ? 'Exporting…' : 'Export all'}
|
||||
disabled={!report?.site?.id || isExportingAll}
|
||||
onClick={() => {
|
||||
handleExportAll().catch(() => null);
|
||||
}}
|
||||
/>
|
||||
<BaseButton
|
||||
color='warning'
|
||||
icon={icon.mdiEmailOutline}
|
||||
label={emailingId === 'all' ? 'Emailing…' : 'Email all'}
|
||||
disabled={!report?.site?.id || emailingId === 'all'}
|
||||
onClick={() => {
|
||||
handleEmailCode().catch(() => null);
|
||||
}}
|
||||
/>
|
||||
</BaseButtons>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
{report?.analysis && (
|
||||
<div className='grid gap-6 xl:grid-cols-[0.9fr,1.1fr]'>
|
||||
<CardBox className='h-full'>
|
||||
<h3 className='text-lg font-semibold text-slate-900 dark:text-white'>Analysis snapshot</h3>
|
||||
<div className='mt-4 grid gap-3 sm:grid-cols-2'>
|
||||
<div className='rounded-2xl border border-slate-200 p-4 dark:border-slate-700'>
|
||||
<div className='text-xs uppercase tracking-wide text-slate-500'>Platform</div>
|
||||
<div className='mt-2 text-lg font-semibold text-slate-900 dark:text-white'>
|
||||
{report.analysis.platform?.label || 'Unknown'}
|
||||
</div>
|
||||
<div className='mt-2 text-xs text-slate-500'>
|
||||
{report.analysis.platform?.matchedSignals?.join(', ') || 'No strong platform signals found.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-slate-200 p-4 dark:border-slate-700'>
|
||||
<div className='text-xs uppercase tracking-wide text-slate-500'>Structured data</div>
|
||||
<div className='mt-2 text-lg font-semibold text-slate-900 dark:text-white'>
|
||||
{report.analysis.schema?.hasStructuredData ? 'Detected' : 'Not detected'}
|
||||
</div>
|
||||
<div className='mt-2 text-xs text-slate-500'>
|
||||
JSON-LD: {report.analysis.schema?.jsonLd?.count || 0} • Microdata: {report.analysis.schema?.microdata?.count || 0} • RDFa: {report.analysis.schema?.rdfa?.count || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-5 space-y-3 text-sm text-slate-600 dark:text-slate-300'>
|
||||
<div>
|
||||
<span className='font-semibold text-slate-900 dark:text-white'>Requested URL:</span>{' '}
|
||||
{report.analysis.requestedUrl || '—'}
|
||||
</div>
|
||||
<div>
|
||||
<span className='font-semibold text-slate-900 dark:text-white'>Analyzed URL:</span>{' '}
|
||||
{report.analysis.analyzedUrl || '—'}
|
||||
</div>
|
||||
<div>
|
||||
<span className='font-semibold text-slate-900 dark:text-white'>Page title:</span>{' '}
|
||||
{report.analysis.pageTitle || '—'}
|
||||
</div>
|
||||
<div>
|
||||
<span className='font-semibold text-slate-900 dark:text-white'>JSON-LD types found:</span>{' '}
|
||||
{(report.analysis.schema?.jsonLd?.types || []).join(', ') || 'None'}
|
||||
</div>
|
||||
{report.analysis.error && (
|
||||
<div className='rounded-xl border border-rose-200 bg-rose-50 p-3 text-rose-700 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-200'>
|
||||
{report.analysis.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='h-full'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between'>
|
||||
<div>
|
||||
<h3 className='text-lg font-semibold text-slate-900 dark:text-white'>Recommendations</h3>
|
||||
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
|
||||
Prioritized next actions with ready-to-copy schema where possible.
|
||||
</p>
|
||||
</div>
|
||||
<div className='text-sm text-slate-500 dark:text-slate-300'>
|
||||
{recommendations.length} recommendation{recommendations.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-5 space-y-4'>
|
||||
{recommendations.length === 0 && (
|
||||
<div className='rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 dark:border-slate-700 dark:text-slate-300'>
|
||||
No recommendations were generated for this page yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recommendations.map((recommendation) => (
|
||||
<div
|
||||
key={recommendation.id}
|
||||
className='rounded-2xl border border-slate-200 p-4 dark:border-slate-700'
|
||||
>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='rounded-full bg-slate-900 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white dark:bg-slate-100 dark:text-slate-900'>
|
||||
{recommendation.priority || 'priority'}
|
||||
</span>
|
||||
{recommendation.schema_type && (
|
||||
<span className='rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700 dark:bg-sky-500/10 dark:text-sky-200'>
|
||||
{recommendation.schema_type}
|
||||
</span>
|
||||
)}
|
||||
{recommendation.page_scope && (
|
||||
<span className='rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'>
|
||||
{recommendation.page_scope}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className='mt-3 text-base font-semibold text-slate-900 dark:text-white'>
|
||||
{recommendation.title}
|
||||
</h4>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
||||
{recommendation.reason}
|
||||
</p>
|
||||
{recommendation.expected_impact && (
|
||||
<p className='mt-2 text-sm leading-6 text-slate-600 dark:text-slate-200'>
|
||||
<span className='font-semibold text-slate-900 dark:text-white'>Expected impact:</span>{' '}
|
||||
{recommendation.expected_impact}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BaseButtons type='justify-start lg:justify-end' noWrap>
|
||||
<BaseButton
|
||||
color='info'
|
||||
small
|
||||
icon={icon.mdiContentCopy}
|
||||
label='Copy'
|
||||
disabled={!recommendation.suggested_schema}
|
||||
onClick={() => {
|
||||
handleCopyCode(recommendation).catch(() => null);
|
||||
}}
|
||||
/>
|
||||
<BaseButton
|
||||
color='success'
|
||||
small
|
||||
icon={icon.mdiDownload}
|
||||
label={exportingId === recommendation.id ? 'Exporting…' : 'Export'}
|
||||
disabled={!recommendation.suggested_schema || exportingId === recommendation.id}
|
||||
onClick={() => {
|
||||
handleExportRecommendation(recommendation).catch(() => null);
|
||||
}}
|
||||
/>
|
||||
<BaseButton
|
||||
color='warning'
|
||||
small
|
||||
icon={icon.mdiEmailOutline}
|
||||
label={emailingId === recommendation.id ? 'Emailing…' : 'Email'}
|
||||
disabled={emailingId === recommendation.id}
|
||||
onClick={() => {
|
||||
handleEmailCode(recommendation.id).catch(() => null);
|
||||
}}
|
||||
/>
|
||||
</BaseButtons>
|
||||
</div>
|
||||
|
||||
<div className='mt-4'>
|
||||
<div className='mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500'>Suggested code</div>
|
||||
<pre className='overflow-x-auto rounded-2xl bg-slate-950 p-4 text-xs leading-6 text-slate-100'>
|
||||
<code>{recommendation.suggested_schema || 'No code snippet generated for this recommendation.'}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToastContainer />
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
SchemaAnalyzerPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default SchemaAnalyzerPage;
|
||||
Loading…
x
Reference in New Issue
Block a user