Autosave: 20260414-153854

This commit is contained in:
Flatlogic Bot 2026-04-14 15:38:53 +00:00
parent 9c9223a7b3
commit b9a2103bb3
17 changed files with 3092 additions and 144 deletions

View File

@ -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;
}
},
};

View File

@ -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;
}
},
};

View 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;
};

View 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;
};

View 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;
};

View File

@ -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;
}
},
};

View File

@ -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;
}
},
};

View File

@ -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;
}
},
};

View File

@ -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 }),

View 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;

File diff suppressed because it is too large Load Diff

View File

@ -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'

View File

@ -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'

View File

@ -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',

View File

@ -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}

View File

@ -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>;
};

View 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;