fixed transitions errors

This commit is contained in:
Dmitri 2026-03-19 20:13:16 +04:00
parent 991ac75f32
commit b2f641a398
67 changed files with 1219 additions and 2897 deletions

View File

@ -5,9 +5,14 @@
"start": "npm run db:migrate && npm run db:seed && npm run watch", "start": "npm run db:migrate && npm run db:seed && npm run watch",
"lint": "eslint . --ext .js", "lint": "eslint . --ext .js",
"db:migrate": "sequelize-cli db:migrate", "db:migrate": "sequelize-cli db:migrate",
"db:migrate:undo": "sequelize-cli db:migrate:undo",
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",
"db:migrate:status": "sequelize-cli db:migrate:status",
"db:seed": "sequelize-cli db:seed:all", "db:seed": "sequelize-cli db:seed:all",
"db:seed:undo": "sequelize-cli db:seed:undo:all",
"db:drop": "sequelize-cli db:drop", "db:drop": "sequelize-cli db:drop",
"db:create": "sequelize-cli db:create", "db:create": "sequelize-cli db:create",
"db:reset": "npm run db:drop && npm run db:create && npm run db:migrate && npm run db:seed",
"watch": "node watcher.js" "watch": "node watcher.js"
}, },
"dependencies": { "dependencies": {

View File

@ -19,11 +19,11 @@ class AssetsDBApi extends GenericDBApi {
} }
static get RANGE_FIELDS() { static get RANGE_FIELDS() {
return ['size_mb', 'width_px', 'height_px', 'duration_sec', 'deleted_at_time']; return ['size_mb', 'width_px', 'height_px', 'duration_sec'];
} }
static get ENUM_FIELDS() { static get ENUM_FIELDS() {
return ['asset_type', 'type', 'is_public', 'is_deleted']; return ['asset_type', 'type', 'is_public'];
} }
static get CSV_FIELDS() { static get CSV_FIELDS() {
@ -68,8 +68,6 @@ class AssetsDBApi extends GenericDBApi {
duration_sec: data.duration_sec || null, duration_sec: data.duration_sec || null,
checksum: data.checksum || null, checksum: data.checksum || null,
is_public: data.is_public || false, is_public: data.is_public || false,
is_deleted: data.is_deleted || false,
deleted_at_time: data.deleted_at_time || null,
}; };
} }

View File

@ -133,14 +133,12 @@ class GenericDBApi {
transaction, transaction,
}); });
await db.sequelize.transaction(async (tx) => { for (const record of records) {
for (const record of records) { await record.update({ deletedBy: currentUser.id }, { transaction });
await record.update({ deletedBy: currentUser.id }, { transaction: tx }); }
} for (const record of records) {
for (const record of records) { await record.destroy({ transaction });
await record.destroy({ transaction: tx }); }
}
});
return records; return records;
} }
@ -163,11 +161,14 @@ class GenericDBApi {
static async findBy(where, options = {}) { static async findBy(where, options = {}) {
const transaction = options.transaction; const transaction = options.transaction;
const include = options.include !== undefined
? options.include
: this.FIND_BY_INCLUDES;
const record = await this.MODEL.findOne({ const record = await this.MODEL.findOne({
where, where,
transaction, transaction,
include: this.FIND_BY_INCLUDES, include,
}); });
if (!record) { if (!record) {

View File

@ -23,11 +23,11 @@ class ProjectsDBApi extends GenericDBApi {
} }
static get RANGE_FIELDS() { static get RANGE_FIELDS() {
return ['deleted_at_time']; return [];
} }
static get ENUM_FIELDS() { static get ENUM_FIELDS() {
return ['phase', 'is_deleted']; return ['phase'];
} }
static get CSV_FIELDS() { static get CSV_FIELDS() {
@ -56,11 +56,27 @@ class ProjectsDBApi extends GenericDBApi {
custom_css_json: data.custom_css_json || null, custom_css_json: data.custom_css_json || null,
cdn_base_url: data.cdn_base_url || null, cdn_base_url: data.cdn_base_url || null,
entry_page_slug: data.entry_page_slug || null, entry_page_slug: data.entry_page_slug || null,
is_deleted: data.is_deleted || false,
deleted_at_time: data.deleted_at_time || null,
}; };
} }
static get DEFAULT_INCLUDES() {
return [];
}
static get ALL_INCLUDES() {
return [
{ association: 'project_memberships_project' },
{ association: 'assets_project' },
{ association: 'presigned_url_requests_project' },
{ association: 'tour_pages_project' },
{ association: 'transitions_project' },
{ association: 'project_audio_tracks_project' },
{ association: 'publish_events_project' },
{ association: 'pwa_caches_project' },
{ association: 'access_logs_project' },
];
}
static async findBy(where, options = {}) { static async findBy(where, options = {}) {
const transaction = options.transaction; const transaction = options.transaction;
const runtimeEnvironment = getRuntimeEnvironment(options); const runtimeEnvironment = getRuntimeEnvironment(options);
@ -77,20 +93,14 @@ class ProjectsDBApi extends GenericDBApi {
queryWhere.slug = runtimeProjectSlug; queryWhere.slug = runtimeProjectSlug;
} }
const include = options.include !== undefined
? options.include
: this.DEFAULT_INCLUDES;
const record = await this.MODEL.findOne({ const record = await this.MODEL.findOne({
where: queryWhere, where: queryWhere,
transaction, transaction,
include: [ include,
{ association: 'project_memberships_project' },
{ association: 'assets_project' },
{ association: 'presigned_url_requests_project' },
{ association: 'tour_pages_project' },
{ association: 'transitions_project' },
{ association: 'project_audio_tracks_project' },
{ association: 'publish_events_project' },
{ association: 'pwa_caches_project' },
{ association: 'access_logs_project' },
],
}); });
if (!record) return null; if (!record) return null;

View File

@ -175,7 +175,7 @@ class Ui_elementsDBApi extends GenericDBApi {
const now = new Date(); const now = new Date();
await this.MODEL.bulkCreate( await this.MODEL.bulkCreate(
this.DEFAULT_ROWS.map((item) => ({ this.DEFAULT_ROWS.map((item) => ({
...item, ...this.getFieldMapping(item),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
})), })),

View File

@ -10,6 +10,8 @@ module.exports = {
port: process.env.DB_PORT, port: process.env.DB_PORT,
logging: false, logging: false,
seederStorage: 'sequelize', seederStorage: 'sequelize',
migrationStorage: 'sequelize',
migrationStorageTableName: 'SequelizeMeta',
}, },
development: { development: {
username: 'postgres', username: 'postgres',
@ -19,6 +21,8 @@ module.exports = {
host: process.env.DB_HOST || 'localhost', host: process.env.DB_HOST || 'localhost',
logging: console.log, logging: console.log,
seederStorage: 'sequelize', seederStorage: 'sequelize',
migrationStorage: 'sequelize',
migrationStorageTableName: 'SequelizeMeta',
}, },
dev_stage: { dev_stage: {
dialect: 'postgres', dialect: 'postgres',
@ -29,5 +33,7 @@ module.exports = {
port: process.env.DB_PORT, port: process.env.DB_PORT,
logging: console.log, logging: console.log,
seederStorage: 'sequelize', seederStorage: 'sequelize',
migrationStorage: 'sequelize',
migrationStorageTableName: 'SequelizeMeta',
} }
}; };

View File

@ -0,0 +1,150 @@
'use strict';
/**
* Migration to add foreign key constraints to all model associations.
* This enforces referential integrity at the database level.
*/
module.exports = {
async up(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
// Helper to add FK constraint safely (checks if exists first)
const addForeignKey = async (tableName, columnName, references, onDelete = 'CASCADE', onUpdate = 'CASCADE') => {
const constraintName = `${tableName}_${columnName}_fkey`;
// Check if constraint already exists
const [results] = await queryInterface.sequelize.query(
`SELECT constraint_name FROM information_schema.table_constraints
WHERE table_name = '${tableName}' AND constraint_name = '${constraintName}'`,
{ transaction }
);
if (results.length === 0) {
await queryInterface.addConstraint(tableName, {
fields: [columnName],
type: 'foreign key',
name: constraintName,
references: {
table: references.table,
field: references.field,
},
onDelete,
onUpdate,
transaction,
});
console.log(`Added FK constraint: ${constraintName}`);
} else {
console.log(`FK constraint already exists: ${constraintName}`);
}
};
// asset_variants -> assets
await addForeignKey('asset_variants', 'assetId', { table: 'assets', field: 'id' }, 'CASCADE', 'CASCADE');
// page_elements -> tour_pages
await addForeignKey('page_elements', 'pageId', { table: 'tour_pages', field: 'id' }, 'CASCADE', 'CASCADE');
// page_links -> tour_pages (from_page)
await addForeignKey('page_links', 'from_pageId', { table: 'tour_pages', field: 'id' }, 'CASCADE', 'CASCADE');
// page_links -> tour_pages (to_page)
await addForeignKey('page_links', 'to_pageId', { table: 'tour_pages', field: 'id' }, 'SET NULL', 'CASCADE');
// page_links -> transitions
await addForeignKey('page_links', 'transitionId', { table: 'transitions', field: 'id' }, 'SET NULL', 'CASCADE');
// assets -> projects
await addForeignKey('assets', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE', 'CASCADE');
// tour_pages -> projects
await addForeignKey('tour_pages', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE', 'CASCADE');
// transitions -> projects
await addForeignKey('transitions', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE', 'CASCADE');
// project_memberships -> projects
await addForeignKey('project_memberships', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE', 'CASCADE');
// project_memberships -> users
await addForeignKey('project_memberships', 'userId', { table: 'users', field: 'id' }, 'CASCADE', 'CASCADE');
// presigned_url_requests -> projects
await addForeignKey('presigned_url_requests', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE', 'CASCADE');
// presigned_url_requests -> users
await addForeignKey('presigned_url_requests', 'userId', { table: 'users', field: 'id' }, 'CASCADE', 'CASCADE');
// project_audio_tracks -> projects
await addForeignKey('project_audio_tracks', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE', 'CASCADE');
// publish_events -> projects
await addForeignKey('publish_events', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE', 'CASCADE');
// publish_events -> users (SET NULL to preserve audit trail)
await addForeignKey('publish_events', 'userId', { table: 'users', field: 'id' }, 'SET NULL', 'CASCADE');
// pwa_caches -> projects
await addForeignKey('pwa_caches', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE', 'CASCADE');
// access_logs -> projects
await addForeignKey('access_logs', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE', 'CASCADE');
// access_logs -> users (SET NULL to preserve audit trail)
await addForeignKey('access_logs', 'userId', { table: 'users', field: 'id' }, 'SET NULL', 'CASCADE');
// users -> roles (SET NULL so deleting role doesn't delete users)
await addForeignKey('users', 'app_roleId', { table: 'roles', field: 'id' }, 'SET NULL', 'CASCADE');
await transaction.commit();
console.log('All FK constraints added successfully');
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
const dropForeignKey = async (tableName, columnName) => {
const constraintName = `${tableName}_${columnName}_fkey`;
try {
await queryInterface.removeConstraint(tableName, constraintName, { transaction });
console.log(`Removed FK constraint: ${constraintName}`);
} catch (error) {
console.log(`FK constraint not found (may not exist): ${constraintName}`);
}
};
// Remove all FK constraints in reverse order
await dropForeignKey('users', 'app_roleId');
await dropForeignKey('access_logs', 'userId');
await dropForeignKey('access_logs', 'projectId');
await dropForeignKey('pwa_caches', 'projectId');
await dropForeignKey('publish_events', 'userId');
await dropForeignKey('publish_events', 'projectId');
await dropForeignKey('project_audio_tracks', 'projectId');
await dropForeignKey('presigned_url_requests', 'userId');
await dropForeignKey('presigned_url_requests', 'projectId');
await dropForeignKey('project_memberships', 'userId');
await dropForeignKey('project_memberships', 'projectId');
await dropForeignKey('transitions', 'projectId');
await dropForeignKey('tour_pages', 'projectId');
await dropForeignKey('assets', 'projectId');
await dropForeignKey('page_links', 'transitionId');
await dropForeignKey('page_links', 'to_pageId');
await dropForeignKey('page_links', 'from_pageId');
await dropForeignKey('page_elements', 'pageId');
await dropForeignKey('asset_variants', 'assetId');
await transaction.commit();
console.log('All FK constraints removed successfully');
} catch (error) {
await transaction.rollback();
throw error;
}
}
};

View File

@ -0,0 +1,100 @@
'use strict';
/**
* Migration to remove redundant deletion tracking columns.
*
* The `is_deleted` and `deleted_at_time` columns are redundant because:
* - Sequelize's `paranoid: true` mode already uses `deletedAt` for soft-delete
* - These columns were set but never queried for filtering
*
* IMPORTANT: This migration should only be run after verifying no external
* systems depend on these columns. Consider backing up data first.
*/
module.exports = {
async up(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
// Helper to safely remove column if it exists
const removeColumnIfExists = async (tableName, columnName) => {
const [results] = await queryInterface.sequelize.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = '${tableName}' AND column_name = '${columnName}'`,
{ transaction }
);
if (results.length > 0) {
await queryInterface.removeColumn(tableName, columnName, { transaction });
console.log(`Removed column: ${tableName}.${columnName}`);
} else {
console.log(`Column does not exist (skipping): ${tableName}.${columnName}`);
}
};
// Remove is_deleted index from assets first (if exists)
try {
await queryInterface.removeIndex('assets', 'assets_is_deleted', { transaction });
console.log('Removed index: assets_is_deleted');
} catch (error) {
console.log('Index assets_is_deleted not found (may not exist)');
}
// Remove redundant columns from assets table
await removeColumnIfExists('assets', 'is_deleted');
await removeColumnIfExists('assets', 'deleted_at_time');
// Remove redundant columns from projects table
await removeColumnIfExists('projects', 'is_deleted');
await removeColumnIfExists('projects', 'deleted_at_time');
await transaction.commit();
console.log('Redundant deletion columns removed successfully');
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
// Re-add columns to assets table
await queryInterface.addColumn('assets', 'is_deleted', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
}, { transaction });
await queryInterface.addColumn('assets', 'deleted_at_time', {
type: Sequelize.DATE,
allowNull: true,
}, { transaction });
// Re-add index
await queryInterface.addIndex('assets', ['is_deleted'], {
name: 'assets_is_deleted',
transaction,
});
// Re-add columns to projects table
await queryInterface.addColumn('projects', 'is_deleted', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
}, { transaction });
await queryInterface.addColumn('projects', 'deleted_at_time', {
type: Sequelize.DATE,
allowNull: true,
}, { transaction });
await transaction.commit();
console.log('Redundant deletion columns restored successfully');
} catch (error) {
await transaction.rollback();
throw error;
}
}
};

View File

@ -105,7 +105,9 @@ accessed_at: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
db.access_logs.belongsTo(db.users, { db.access_logs.belongsTo(db.users, {
@ -113,7 +115,9 @@ accessed_at: {
foreignKey: { foreignKey: {
name: 'userId', name: 'userId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });

View File

@ -114,7 +114,9 @@ size_mb: {
foreignKey: { foreignKey: {
name: 'assetId', name: 'assetId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });

View File

@ -137,23 +137,6 @@ is_public: {
},
is_deleted: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
deleted_at_time: {
type: DataTypes.DATE,
}, },
importHash: { importHash: {
@ -171,7 +154,6 @@ deleted_at_time: {
{ fields: ['asset_type'] }, { fields: ['asset_type'] },
{ fields: ['type'] }, { fields: ['type'] },
{ fields: ['is_public'] }, { fields: ['is_public'] },
{ fields: ['is_deleted'] },
{ fields: ['deletedAt'] }, { fields: ['deletedAt'] },
], ],
}, },
@ -194,7 +176,9 @@ deleted_at_time: {
foreignKey: { foreignKey: {
name: 'assetId', name: 'assetId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
@ -217,7 +201,9 @@ deleted_at_time: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });

View File

@ -163,7 +163,9 @@ content_json: {
name: 'pageId', name: 'pageId',
allowNull: false, allowNull: false,
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });

View File

@ -104,7 +104,9 @@ trigger_selector: {
foreignKey: { foreignKey: {
name: 'from_pageId', name: 'from_pageId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
db.page_links.belongsTo(db.tour_pages, { db.page_links.belongsTo(db.tour_pages, {
@ -112,7 +114,9 @@ trigger_selector: {
foreignKey: { foreignKey: {
name: 'to_pageId', name: 'to_pageId',
}, },
constraints: false, constraints: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
}); });
db.page_links.belongsTo(db.transitions, { db.page_links.belongsTo(db.transitions, {
@ -120,7 +124,9 @@ trigger_selector: {
foreignKey: { foreignKey: {
name: 'transitionId', name: 'transitionId',
}, },
constraints: false, constraints: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
}); });

View File

@ -131,7 +131,9 @@ status: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
db.presigned_url_requests.belongsTo(db.users, { db.presigned_url_requests.belongsTo(db.users, {
@ -139,7 +141,9 @@ status: {
foreignKey: { foreignKey: {
name: 'userId', name: 'userId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });

View File

@ -135,7 +135,9 @@ is_enabled: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });

View File

@ -106,7 +106,9 @@ accepted_at: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
db.project_memberships.belongsTo(db.users, { db.project_memberships.belongsTo(db.users, {
@ -114,7 +116,9 @@ accepted_at: {
foreignKey: { foreignKey: {
name: 'userId', name: 'userId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });

View File

@ -95,23 +95,6 @@ entry_page_slug: {
},
is_deleted: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
deleted_at_time: {
type: DataTypes.DATE,
}, },
importHash: { importHash: {
@ -147,7 +130,9 @@ deleted_at_time: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
@ -156,7 +141,9 @@ deleted_at_time: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
@ -166,7 +153,9 @@ deleted_at_time: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
@ -175,7 +164,9 @@ deleted_at_time: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
@ -186,7 +177,9 @@ deleted_at_time: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
@ -195,7 +188,9 @@ deleted_at_time: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
@ -204,7 +199,9 @@ deleted_at_time: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
@ -213,7 +210,9 @@ deleted_at_time: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
@ -222,7 +221,9 @@ deleted_at_time: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });

View File

@ -175,7 +175,9 @@ audios_copied: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
db.publish_events.belongsTo(db.users, { db.publish_events.belongsTo(db.users, {
@ -183,7 +185,9 @@ audios_copied: {
foreignKey: { foreignKey: {
name: 'userId', name: 'userId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });

View File

@ -104,7 +104,9 @@ is_active: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });

View File

@ -44,7 +44,8 @@ role_customization: {
foreignKey: { foreignKey: {
name: 'roles_permissionsId', name: 'roles_permissionsId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
through: 'rolesPermissionsPermissions', through: 'rolesPermissionsPermissions',
}); });
@ -53,7 +54,8 @@ role_customization: {
foreignKey: { foreignKey: {
name: 'roles_permissionsId', name: 'roles_permissionsId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
through: 'rolesPermissionsPermissions', through: 'rolesPermissionsPermissions',
}); });
@ -66,7 +68,9 @@ role_customization: {
foreignKey: { foreignKey: {
name: 'app_roleId', name: 'app_roleId',
}, },
constraints: false, constraints: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
}); });

View File

@ -144,7 +144,9 @@ ui_schema_json: {
foreignKey: { foreignKey: {
name: 'pageId', name: 'pageId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
@ -153,7 +155,9 @@ ui_schema_json: {
foreignKey: { foreignKey: {
name: 'from_pageId', name: 'from_pageId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
db.tour_pages.hasMany(db.page_links, { db.tour_pages.hasMany(db.page_links, {
@ -161,7 +165,9 @@ ui_schema_json: {
foreignKey: { foreignKey: {
name: 'to_pageId', name: 'to_pageId',
}, },
constraints: false, constraints: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
}); });
@ -180,7 +186,9 @@ ui_schema_json: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });

View File

@ -123,7 +123,9 @@ duration_sec: {
foreignKey: { foreignKey: {
name: 'transitionId', name: 'transitionId',
}, },
constraints: false, constraints: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
}); });
@ -142,7 +144,9 @@ duration_sec: {
foreignKey: { foreignKey: {
name: 'projectId', name: 'projectId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });

View File

@ -130,7 +130,8 @@ provider: {
foreignKey: { foreignKey: {
name: 'users_custom_permissionsId', name: 'users_custom_permissionsId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
through: 'usersCustom_permissionsPermissions', through: 'usersCustom_permissionsPermissions',
}); });
@ -139,7 +140,8 @@ provider: {
foreignKey: { foreignKey: {
name: 'users_custom_permissionsId', name: 'users_custom_permissionsId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
through: 'usersCustom_permissionsPermissions', through: 'usersCustom_permissionsPermissions',
}); });
@ -156,7 +158,9 @@ provider: {
foreignKey: { foreignKey: {
name: 'userId', name: 'userId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
@ -167,7 +171,9 @@ provider: {
foreignKey: { foreignKey: {
name: 'userId', name: 'userId',
}, },
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
}); });
@ -181,7 +187,9 @@ provider: {
foreignKey: { foreignKey: {
name: 'userId', name: 'userId',
}, },
constraints: false, constraints: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
}); });
@ -191,7 +199,9 @@ provider: {
foreignKey: { foreignKey: {
name: 'userId', name: 'userId',
}, },
constraints: false, constraints: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
}); });
@ -205,7 +215,9 @@ provider: {
foreignKey: { foreignKey: {
name: 'app_roleId', name: 'app_roleId',
}, },
constraints: false, constraints: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
}); });
@ -213,7 +225,9 @@ provider: {
db.users.hasMany(db.file, { db.users.hasMany(db.file, {
as: 'avatar', as: 'avatar',
foreignKey: 'belongsToId', foreignKey: 'belongsToId',
constraints: false, constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
scope: { scope: {
belongsTo: db.users.getTableName(), belongsTo: db.users.getTableName(),
belongsToColumn: 'avatar', belongsToColumn: 'avatar',

View File

@ -1,9 +1,14 @@
const db = require('./models'); const db = require('./models');
async function syncDatabase() { async function syncDatabase() {
if (process.env.NODE_ENV === 'production') {
console.error('ERROR: sync.js should not be run in production. Use migrations instead.');
process.exit(1);
}
try { try {
console.log('Syncing database...'); console.log('Syncing database...');
await db.sequelize.sync({ force: true }); await db.sequelize.sync({ alter: true });
console.log('Database synced successfully!'); console.log('Database synced successfully!');
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {

View File

@ -1,11 +1,8 @@
const express = require('express'); const express = require('express');
const { wrapAsync, commonErrorHandler } = require('../helpers'); const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
const { checkCrudPermissions } = require('../middlewares/check-permissions'); const { checkCrudPermissions } = require('../middlewares/check-permissions');
const { parse } = require('json2csv'); const { parse } = require('json2csv');
const isUuidV4 = (value) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
function createEntityRouter(entityName, Service, DBApi, options = {}) { function createEntityRouter(entityName, Service, DBApi, options = {}) {
const router = express.Router(); const router = express.Router();
@ -70,14 +67,14 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
res.status(200).send(payload); res.status(200).send(payload);
})); }));
router.get('/autocomplete', async (req, res) => { router.get('/autocomplete', wrapAsync(async (req, res) => {
const payload = await DBApi.findAllAutocomplete( const payload = await DBApi.findAllAutocomplete(
req.query.query, req.query.query,
req.query.limit, req.query.limit,
req.query.offset req.query.offset
); );
res.status(200).send(payload); res.status(200).send(payload);
}); }));
router.get('/:id', wrapAsync(async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) { if (!isUuidV4(req.params.id)) {

View File

@ -22,4 +22,8 @@ module.exports = class Helpers {
static jwtSign(data) { static jwtSign(data) {
return jwt.sign(data, config.secret_key, {expiresIn: '6h'}); return jwt.sign(data, config.secret_key, {expiresIn: '6h'});
} }
static isUuidV4(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
}; };

View File

@ -3,7 +3,7 @@ const express = require('express');
const ProjectsService = require('../services/projects'); const ProjectsService = require('../services/projects');
const ProjectsDBApi = require('../db/api/projects'); const ProjectsDBApi = require('../db/api/projects');
const wrapAsync = require('../helpers').wrapAsync; const { wrapAsync, isUuidV4 } = require('../helpers');
const router = express.Router(); const router = express.Router();
@ -17,9 +17,6 @@ const {
router.use(checkCrudPermissions('projects')); router.use(checkCrudPermissions('projects'));
const isUuidV4 = (value) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
/** /**
* @swagger * @swagger
@ -328,11 +325,7 @@ router.get('/', wrapAsync(async (req, res) => {
req.query, { currentUser, runtimeContext } req.query, { currentUser, runtimeContext }
); );
if (filetype && filetype === 'csv') { if (filetype && filetype === 'csv') {
const fields = ['id','name','slug','description','logo_url','favicon_url','og_image_url','theme_config_json','custom_css_json','cdn_base_url','entry_page_slug', const fields = ['id','name','slug','description','logo_url','favicon_url','og_image_url','theme_config_json','custom_css_json','cdn_base_url','entry_page_slug'];
'deleted_at_time',
];
const opts = { fields }; const opts = { fields };
try { try {
const csv = parse(payload.rows, opts); const csv = parse(payload.rows, opts);

View File

@ -1,136 +1,6 @@
const db = require('../db/models');
const Access_logsDBApi = require('../db/api/access_logs'); const Access_logsDBApi = require('../db/api/access_logs');
const processFile = require("../middlewares/upload"); const { createEntityService } = require('../factories/service.factory');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
module.exports = class Access_logsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Access_logsDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await Access_logsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let access_logs = await Access_logsDBApi.findBy(
{id},
{transaction},
);
if (!access_logs) {
throw new ValidationError(
'access_logsNotFound',
);
}
const updatedAccess_logs = await Access_logsDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedAccess_logs;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Access_logsDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Access_logsDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};
module.exports = createEntityService(Access_logsDBApi, {
entityName: 'access_logs',
});

View File

@ -1,136 +1,6 @@
const db = require('../db/models');
const Asset_variantsDBApi = require('../db/api/asset_variants'); const Asset_variantsDBApi = require('../db/api/asset_variants');
const processFile = require("../middlewares/upload"); const { createEntityService } = require('../factories/service.factory');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
module.exports = class Asset_variantsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Asset_variantsDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await Asset_variantsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let asset_variants = await Asset_variantsDBApi.findBy(
{id},
{transaction},
);
if (!asset_variants) {
throw new ValidationError(
'asset_variantsNotFound',
);
}
const updatedAsset_variants = await Asset_variantsDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedAsset_variants;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Asset_variantsDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Asset_variantsDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};
module.exports = createEntityService(Asset_variantsDBApi, {
entityName: 'asset_variants',
});

View File

@ -1,136 +1,6 @@
const db = require('../db/models');
const AssetsDBApi = require('../db/api/assets'); const AssetsDBApi = require('../db/api/assets');
const processFile = require("../middlewares/upload"); const { createEntityService } = require('../factories/service.factory');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
module.exports = class AssetsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await AssetsDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await AssetsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let assets = await AssetsDBApi.findBy(
{id},
{transaction},
);
if (!assets) {
throw new ValidationError(
'assetsNotFound',
);
}
const updatedAssets = await AssetsDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedAssets;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await AssetsDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await AssetsDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};
module.exports = createEntityService(AssetsDBApi, {
entityName: 'assets',
});

View File

@ -186,6 +186,7 @@ const downloadLocal = async (req, res) => {
if (!privateUrl) { if (!privateUrl) {
return res.sendStatus(404); return res.sendStatus(404);
} }
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.download(path.join(config.uploadDir, privateUrl)); res.download(path.join(config.uploadDir, privateUrl));
} }
@ -348,6 +349,7 @@ const downloadGCloud = async (req, res) => {
const fileExists = await file.exists(); const fileExists = await file.exists();
if (fileExists[0]) { if (fileExists[0]) {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
const stream = file.createReadStream(); const stream = file.createReadStream();
stream.pipe(res); stream.pipe(res);
} }
@ -428,6 +430,7 @@ const downloadS3 = async (req, res) => {
if (output.ContentType) { if (output.ContentType) {
res.setHeader('Content-Type', output.ContentType); res.setHeader('Content-Type', output.ContentType);
} }
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
if (typeof output.Body.pipe === 'function') { if (typeof output.Body.pipe === 'function') {
output.Body.pipe(res); output.Body.pipe(res);

View File

@ -1,136 +1,6 @@
const db = require('../db/models');
const Page_elementsDBApi = require('../db/api/page_elements'); const Page_elementsDBApi = require('../db/api/page_elements');
const processFile = require("../middlewares/upload"); const { createEntityService } = require('../factories/service.factory');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
module.exports = class Page_elementsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Page_elementsDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await Page_elementsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let page_elements = await Page_elementsDBApi.findBy(
{id},
{transaction},
);
if (!page_elements) {
throw new ValidationError(
'page_elementsNotFound',
);
}
const updatedPage_elements = await Page_elementsDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedPage_elements;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Page_elementsDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Page_elementsDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};
module.exports = createEntityService(Page_elementsDBApi, {
entityName: 'page_elements',
});

View File

@ -1,136 +1,6 @@
const db = require('../db/models');
const Page_linksDBApi = require('../db/api/page_links'); const Page_linksDBApi = require('../db/api/page_links');
const processFile = require("../middlewares/upload"); const { createEntityService } = require('../factories/service.factory');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
module.exports = class Page_linksService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Page_linksDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await Page_linksDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let page_links = await Page_linksDBApi.findBy(
{id},
{transaction},
);
if (!page_links) {
throw new ValidationError(
'page_linksNotFound',
);
}
const updatedPage_links = await Page_linksDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedPage_links;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Page_linksDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Page_linksDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};
module.exports = createEntityService(Page_linksDBApi, {
entityName: 'page_links',
});

View File

@ -1,136 +1,6 @@
const db = require('../db/models');
const PermissionsDBApi = require('../db/api/permissions'); const PermissionsDBApi = require('../db/api/permissions');
const processFile = require("../middlewares/upload"); const { createEntityService } = require('../factories/service.factory');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
module.exports = class PermissionsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await PermissionsDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await PermissionsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let permissions = await PermissionsDBApi.findBy(
{id},
{transaction},
);
if (!permissions) {
throw new ValidationError(
'permissionsNotFound',
);
}
const updatedPermissions = await PermissionsDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedPermissions;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await PermissionsDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await PermissionsDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};
module.exports = createEntityService(PermissionsDBApi, {
entityName: 'permissions',
});

View File

@ -1,136 +1,6 @@
const db = require('../db/models');
const Presigned_url_requestsDBApi = require('../db/api/presigned_url_requests'); const Presigned_url_requestsDBApi = require('../db/api/presigned_url_requests');
const processFile = require("../middlewares/upload"); const { createEntityService } = require('../factories/service.factory');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
module.exports = class Presigned_url_requestsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Presigned_url_requestsDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await Presigned_url_requestsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let presigned_url_requests = await Presigned_url_requestsDBApi.findBy(
{id},
{transaction},
);
if (!presigned_url_requests) {
throw new ValidationError(
'presigned_url_requestsNotFound',
);
}
const updatedPresigned_url_requests = await Presigned_url_requestsDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedPresigned_url_requests;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Presigned_url_requestsDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Presigned_url_requestsDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};
module.exports = createEntityService(Presigned_url_requestsDBApi, {
entityName: 'presigned_url_requests',
});

View File

@ -13,7 +13,7 @@ module.exports = class Project_audio_tracksService {
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await Project_audio_tracksDBApi.create( const createdTrack = await Project_audio_tracksDBApi.create(
data, data,
{ {
currentUser, currentUser,
@ -22,6 +22,7 @@ module.exports = class Project_audio_tracksService {
); );
await transaction.commit(); await transaction.commit();
return createdTrack;
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;

View File

@ -1,136 +1,6 @@
const db = require('../db/models');
const Project_membershipsDBApi = require('../db/api/project_memberships'); const Project_membershipsDBApi = require('../db/api/project_memberships');
const processFile = require("../middlewares/upload"); const { createEntityService } = require('../factories/service.factory');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
module.exports = class Project_membershipsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Project_membershipsDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await Project_membershipsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let project_memberships = await Project_membershipsDBApi.findBy(
{id},
{transaction},
);
if (!project_memberships) {
throw new ValidationError(
'project_membershipsNotFound',
);
}
const updatedProject_memberships = await Project_membershipsDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedProject_memberships;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Project_membershipsDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Project_membershipsDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};
module.exports = createEntityService(Project_membershipsDBApi, {
entityName: 'project_memberships',
});

View File

@ -106,8 +106,6 @@ module.exports = class ProjectsService {
custom_css_json: sourceProject.custom_css_json, custom_css_json: sourceProject.custom_css_json,
cdn_base_url: sourceProject.cdn_base_url, cdn_base_url: sourceProject.cdn_base_url,
entry_page_slug: sourceProject.entry_page_slug, entry_page_slug: sourceProject.entry_page_slug,
is_deleted: false,
deleted_at_time: null,
}, },
{ {
currentUser, currentUser,
@ -130,8 +128,6 @@ module.exports = class ProjectsService {
duration_sec: sourceAsset.duration_sec, duration_sec: sourceAsset.duration_sec,
checksum: sourceAsset.checksum, checksum: sourceAsset.checksum,
is_public: sourceAsset.is_public, is_public: sourceAsset.is_public,
is_deleted: false,
deleted_at_time: null,
projectId: clonedProject.id, projectId: clonedProject.id,
createdById: currentUser.id, createdById: currentUser.id,
updatedById: currentUser.id, updatedById: currentUser.id,

View File

@ -1,136 +1,6 @@
const db = require('../db/models');
const Publish_eventsDBApi = require('../db/api/publish_events'); const Publish_eventsDBApi = require('../db/api/publish_events');
const processFile = require("../middlewares/upload"); const { createEntityService } = require('../factories/service.factory');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
module.exports = class Publish_eventsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Publish_eventsDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await Publish_eventsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let publish_events = await Publish_eventsDBApi.findBy(
{id},
{transaction},
);
if (!publish_events) {
throw new ValidationError(
'publish_eventsNotFound',
);
}
const updatedPublish_events = await Publish_eventsDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedPublish_events;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Publish_eventsDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Publish_eventsDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};
module.exports = createEntityService(Publish_eventsDBApi, {
entityName: 'publish_events',
});

View File

@ -1,136 +1,6 @@
const db = require('../db/models');
const Pwa_cachesDBApi = require('../db/api/pwa_caches'); const Pwa_cachesDBApi = require('../db/api/pwa_caches');
const processFile = require("../middlewares/upload"); const { createEntityService } = require('../factories/service.factory');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
module.exports = class Pwa_cachesService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Pwa_cachesDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await Pwa_cachesDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let pwa_caches = await Pwa_cachesDBApi.findBy(
{id},
{transaction},
);
if (!pwa_caches) {
throw new ValidationError(
'pwa_cachesNotFound',
);
}
const updatedPwa_caches = await Pwa_cachesDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedPwa_caches;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Pwa_cachesDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Pwa_cachesDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};
module.exports = createEntityService(Pwa_cachesDBApi, {
entityName: 'pwa_caches',
});

View File

@ -59,7 +59,7 @@ module.exports = class RolesService {
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await RolesDBApi.create( const createdRole = await RolesDBApi.create(
data, data,
{ {
currentUser, currentUser,
@ -68,6 +68,7 @@ module.exports = class RolesService {
); );
await transaction.commit(); await transaction.commit();
return createdRole;
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;

View File

@ -1,136 +1,6 @@
const db = require('../db/models');
const Tour_pagesDBApi = require('../db/api/tour_pages'); const Tour_pagesDBApi = require('../db/api/tour_pages');
const processFile = require("../middlewares/upload"); const { createEntityService } = require('../factories/service.factory');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
module.exports = class Tour_pagesService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Tour_pagesDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await Tour_pagesDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let tour_pages = await Tour_pagesDBApi.findBy(
{id},
{transaction},
);
if (!tour_pages) {
throw new ValidationError(
'tour_pagesNotFound',
);
}
const updatedTour_pages = await Tour_pagesDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedTour_pages;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Tour_pagesDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Tour_pagesDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};
module.exports = createEntityService(Tour_pagesDBApi, {
entityName: 'tour_pages',
});

View File

@ -1,136 +1,6 @@
const db = require('../db/models');
const TransitionsDBApi = require('../db/api/transitions'); const TransitionsDBApi = require('../db/api/transitions');
const processFile = require("../middlewares/upload"); const { createEntityService } = require('../factories/service.factory');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
module.exports = class TransitionsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await TransitionsDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await TransitionsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let transitions = await TransitionsDBApi.findBy(
{id},
{transaction},
);
if (!transitions) {
throw new ValidationError(
'transitionsNotFound',
);
}
const updatedTransitions = await TransitionsDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedTransitions;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await TransitionsDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await TransitionsDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};
module.exports = createEntityService(TransitionsDBApi, {
entityName: 'transitions',
});

View File

@ -2,11 +2,7 @@ import React from 'react';
import BaseIcon from '../BaseIcon'; import BaseIcon from '../BaseIcon';
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
import axios from 'axios'; import axios from 'axios';
import { import { GridActionsCellItem, GridRowParams } from '@mui/x-data-grid';
GridActionsCellItem,
GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';
import dataFormatter from '../../helpers/dataFormatter'; import dataFormatter from '../../helpers/dataFormatter';
@ -54,8 +50,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'), valueOptions: await callOptionsApi('projects'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {
@ -86,8 +81,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('users'), valueOptions: await callOptionsApi('users'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {
@ -138,8 +132,7 @@ export const loadColumns = async (
editable: hasUpdatePermission, editable: hasUpdatePermission,
type: 'dateTime', type: 'dateTime',
valueGetter: (params: GridValueGetterParams) => valueGetter: (_value, row) => new Date(row.accessed_at),
new Date(params.row.accessed_at),
}, },
{ {

View File

@ -2,11 +2,7 @@ import React from 'react';
import BaseIcon from '../BaseIcon'; import BaseIcon from '../BaseIcon';
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
import axios from 'axios'; import axios from 'axios';
import { import { GridActionsCellItem, GridRowParams } from '@mui/x-data-grid';
GridActionsCellItem,
GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';
import dataFormatter from '../../helpers/dataFormatter'; import dataFormatter from '../../helpers/dataFormatter';
@ -54,8 +50,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('assets'), valueOptions: await callOptionsApi('assets'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {

View File

@ -0,0 +1,154 @@
import React, { useState } from 'react';
import BaseButton from '../BaseButton';
import CardBox from '../CardBox';
import UploadProgressList, { UploadQueueItem } from './UploadProgressList';
export type Asset = {
id: string;
name: string;
asset_type: 'image' | 'video' | 'audio' | 'file';
type?:
| 'icon'
| 'background_image'
| 'audio'
| 'video'
| 'transition'
| 'logo'
| 'favicon'
| 'document'
| 'general';
cdn_url?: string | null;
mime_type?: string | null;
};
export type AssetSection = {
key:
| 'images'
| 'backgroundImages'
| 'audio'
| 'video'
| 'transitions'
| 'logo';
label: string;
accept: string;
assetFormat: 'image' | 'video' | 'audio';
assetCategory: NonNullable<Asset['type']>;
legacyTag: string;
};
type AssetSectionCardProps = {
section: AssetSection;
assets: Asset[];
uploadQueue: UploadQueueItem[];
isUploading: boolean;
isLoadingAssets: boolean;
hasCreatePermission: boolean;
hasDeletePermission: boolean;
deletingAssetId: string;
onUpload: (files: File[]) => void;
onDeleteAsset: (assetId: string) => void;
disabled: boolean;
};
const AssetSectionCard: React.FC<AssetSectionCardProps> = ({
section,
assets,
uploadQueue,
isUploading,
isLoadingAssets,
hasCreatePermission,
hasDeletePermission,
deletingAssetId,
onUpload,
onDeleteAsset,
disabled,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
onUpload(files);
event.currentTarget.value = '';
};
return (
<CardBox className='h-full'>
<div className='flex items-center justify-between gap-3 mb-4'>
<h3 className='text-lg font-semibold'>{section.label}</h3>
{!hasCreatePermission && (
<p className='text-xs text-gray-500'>
You do not have upload permission
</p>
)}
</div>
<input
type='file'
multiple
accept={section.accept}
className='w-full border border-gray-300 rounded px-2 py-2 mb-3 bg-white dark:bg-dark-800'
disabled={isUploading || disabled || !hasCreatePermission}
onChange={handleFileChange}
/>
{isUploading && (
<p className='text-sm text-gray-500 mb-3'>
Uploading {section.label.toLowerCase()} in chunks...
</p>
)}
<UploadProgressList items={uploadQueue} />
{isLoadingAssets && (
<p className='text-sm text-gray-500'>Loading assets...</p>
)}
{!isLoadingAssets && assets.length > 0 && (
<button
type='button'
className='mb-2 text-xs underline'
onClick={() => setIsExpanded((prev) => !prev)}
>
{isExpanded
? `Hide uploaded ${section.label.toLowerCase()}`
: `Show uploaded ${section.label.toLowerCase()} (${assets.length})`}
</button>
)}
{!isLoadingAssets && assets.length === 0 && (
<p className='text-sm text-gray-500'>
No uploaded {section.label.toLowerCase()}.
</p>
)}
{!isLoadingAssets && assets.length > 0 && isExpanded && (
<ul className='space-y-2'>
{assets.map((asset) => (
<li
key={asset.id}
className='flex items-center justify-between gap-2 p-2 border border-gray-200 dark:border-dark-700 rounded'
>
<a
href={asset.cdn_url || '#'}
target='_blank'
rel='noreferrer'
className='text-sm underline truncate'
>
{asset.name.replace(/^\[[^\]]+\]\s*/, '')}
</a>
<BaseButton
color='danger'
label='X'
small
disabled={deletingAssetId === asset.id || !hasDeletePermission}
onClick={() => onDeleteAsset(asset.id)}
/>
</li>
))}
</ul>
)}
</CardBox>
);
};
export default AssetSectionCard;

View File

@ -0,0 +1,114 @@
import axios from 'axios';
import { useRouter } from 'next/router';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
export type Project = {
id: string;
name: string;
};
interface UseProjectSelectorOptions {
currentUser: unknown;
}
interface UseProjectSelectorReturn {
projects: Project[];
selectedProjectId: string;
isLoadingProjects: boolean;
selectedProjectName: string;
}
export function useProjectSelector({
currentUser,
}: UseProjectSelectorOptions): UseProjectSelectorReturn {
const router = useRouter();
const routeProjectId = useMemo(() => {
const value = router.query.projectId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.projectId]);
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState('');
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const loadProjects = useCallback(async () => {
setIsLoadingProjects(true);
try {
let rows: Project[] = [];
const response = await axios.get(
'/projects?limit=100&page=0&sort=desc&field=updatedAt',
);
rows = Array.isArray(response?.data?.rows) ? response.data.rows : [];
if (rows.length === 0) {
const autocompleteResponse = await axios.get(
'/projects/autocomplete?limit=100',
);
const autocompleteItems = Array.isArray(autocompleteResponse?.data)
? autocompleteResponse.data
: [];
rows = autocompleteItems.map((item: { id: string; label: string }) => ({
id: item.id,
name: item.label,
}));
}
if (rows.length === 0) {
toast('Please create a project first', {
type: 'info',
position: 'bottom-center',
});
router.replace('/projects/projects-new');
return;
}
setProjects(rows);
if (
routeProjectId &&
rows.some((project) => project.id === routeProjectId)
) {
setSelectedProjectId(routeProjectId);
} else {
setSelectedProjectId((prev) => {
if (rows.some((project) => project.id === prev)) return prev;
return rows[0]?.id || '';
});
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error('Failed to load projects:', errorMessage);
setProjects([]);
setSelectedProjectId('');
toast('Failed to load projects', {
type: 'error',
position: 'bottom-center',
});
} finally {
setIsLoadingProjects(false);
}
}, [routeProjectId, router]);
useEffect(() => {
if (!currentUser) return;
loadProjects();
}, [routeProjectId, currentUser, loadProjects]);
const selectedProjectName = useMemo(() => {
return projects.find((p) => p.id === selectedProjectId)?.name || '';
}, [projects, selectedProjectId]);
return {
projects,
selectedProjectId,
isLoadingProjects,
selectedProjectName,
};
}
export default useProjectSelector;

View File

@ -0,0 +1,56 @@
import React from 'react';
export type UploadQueueItem = {
id: string;
fileName: string;
progress: number;
status: 'queued' | 'uploading' | 'saving' | 'success' | 'error';
error?: string;
};
type UploadProgressListProps = {
items: UploadQueueItem[];
};
const UploadProgressList: React.FC<UploadProgressListProps> = ({ items }) => {
const pendingItems = items.filter((item) => item.status !== 'success');
if (pendingItems.length === 0) {
return null;
}
return (
<ul className='space-y-1 mb-3 max-h-40 overflow-auto'>
{pendingItems.map((item) => (
<li
key={item.id}
className='text-xs border border-gray-200 dark:border-dark-700 rounded p-2'
>
<div className='flex items-center justify-between gap-2'>
<span className='truncate'>{item.fileName}</span>
<span>
{item.status === 'error'
? 'Error'
: item.status === 'success'
? 'Done'
: item.status}
</span>
</div>
{item.status !== 'success' && (
<div className='w-full h-1 bg-gray-200 rounded mt-1'>
<div
className='h-1 bg-blue-500 rounded'
style={{ width: `${item.progress}%` }}
/>
</div>
)}
{item.error && (
<p className='text-red-500 mt-1 truncate'>{item.error}</p>
)}
</li>
))}
</ul>
);
};
export default UploadProgressList;

View File

@ -2,11 +2,7 @@ import React from 'react';
import BaseIcon from '../BaseIcon'; import BaseIcon from '../BaseIcon';
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
import axios from 'axios'; import axios from 'axios';
import { import { GridActionsCellItem, GridRowParams } from '@mui/x-data-grid';
GridActionsCellItem,
GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';
import dataFormatter from '../../helpers/dataFormatter'; import dataFormatter from '../../helpers/dataFormatter';
@ -54,8 +50,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'), valueOptions: await callOptionsApi('projects'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {
@ -238,8 +233,7 @@ export const loadColumns = async (
editable: hasUpdatePermission, editable: hasUpdatePermission,
type: 'dateTime', type: 'dateTime',
valueGetter: (params: GridValueGetterParams) => valueGetter: (_value, row) => new Date(row.deleted_at_time),
new Date(params.row.deleted_at_time),
}, },
{ {

View File

@ -0,0 +1,244 @@
import axios from 'axios';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import FileUploader from '../Uploaders/UploadService';
import type { AssetSection } from './AssetSectionCard';
import type { UploadQueueItem } from './UploadProgressList';
interface UseAssetUploaderOptions {
selectedProjectId: string;
onUploadComplete: () => void;
}
interface UseAssetUploaderReturn {
uploadingSections: string[];
uploadQueues: Record<string, UploadQueueItem[]>;
runBatchUpload: (section: AssetSection, files: File[]) => Promise<void>;
}
export function useAssetUploader({
selectedProjectId,
onUploadComplete,
}: UseAssetUploaderOptions): UseAssetUploaderReturn {
const abortControllerRef = useRef<AbortController | null>(null);
const [uploadingSections, setUploadingSections] = useState<string[]>([]);
const [uploadQueues, setUploadQueues] = useState<
Record<string, UploadQueueItem[]>
>({});
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
const addSectionUpload = useCallback(
(sectionKey: string, items: UploadQueueItem[]) => {
setUploadQueues((prev) => ({
...prev,
[sectionKey]: [...(prev[sectionKey] || []), ...items],
}));
},
[],
);
const updateSectionUpload = useCallback(
(sectionKey: string, itemId: string, patch: Partial<UploadQueueItem>) => {
setUploadQueues((prev) => ({
...prev,
[sectionKey]: (prev[sectionKey] || []).map((item) =>
item.id === itemId ? { ...item, ...patch } : item,
),
}));
},
[],
);
const markSectionUploading = useCallback(
(sectionKey: string, isUploading: boolean) => {
setUploadingSections((prev) => {
if (isUploading) {
if (prev.includes(sectionKey)) return prev;
return [...prev, sectionKey];
}
return prev.filter((key) => key !== sectionKey);
});
},
[],
);
const uploadAssetFile = useCallback(
async (
section: AssetSection,
projectId: string,
itemId: string,
file: File,
signal?: AbortSignal,
) => {
updateSectionUpload(section.key, itemId, {
status: 'uploading',
progress: 0,
});
const remoteFile = await FileUploader.uploadChunked(
`assets/${projectId}`,
file,
{},
{
chunkSize: 5 * 1024 * 1024,
maxRetries: 3,
signal,
onProgress: (progress: number) => {
updateSectionUpload(section.key, itemId, {
progress,
status: 'uploading',
});
},
onStatus: (status: string) => {
if (status === 'finalizing') {
updateSectionUpload(section.key, itemId, { status: 'saving' });
}
},
},
);
await axios.post('/assets', {
data: {
project: projectId,
name: file.name,
asset_type: section.assetFormat,
type: section.assetCategory,
cdn_url: remoteFile.publicUrl,
storage_key: remoteFile.privateUrl,
mime_type: file.type || null,
size_mb: Number((file.size / (1024 * 1024)).toFixed(4)),
is_public: false,
},
});
updateSectionUpload(section.key, itemId, {
status: 'success',
progress: 100,
});
},
[updateSectionUpload],
);
const runBatchUpload = useCallback(
async (section: AssetSection, files: File[]) => {
if (!selectedProjectId) {
toast('Select a project first', {
type: 'warning',
position: 'bottom-center',
});
return;
}
if (!files.length) return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
const queueItems: UploadQueueItem[] = files.map((file) => ({
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
fileName: file.name,
progress: 0,
status: 'queued',
}));
addSectionUpload(section.key, queueItems);
markSectionUploading(section.key, true);
const queue = files.map((file, index) => ({
file,
itemId: queueItems[index].id,
}));
const maxConcurrent = 2;
let currentIndex = 0;
let failedCount = 0;
const worker = async () => {
while (currentIndex < queue.length) {
if (signal.aborted) break;
const nextIndex = currentIndex;
currentIndex += 1;
const item = queue[nextIndex];
try {
await uploadAssetFile(
section,
selectedProjectId,
item.itemId,
item.file,
signal,
);
} catch (error: unknown) {
if (signal.aborted) break;
const axiosError = error as {
response?: { data?: { message?: string } };
message?: string;
};
const message =
axiosError?.response?.data?.message ||
axiosError?.message ||
'Upload failed';
console.error(`Failed to upload ${item.file.name}:`, error);
failedCount += 1;
updateSectionUpload(section.key, item.itemId, {
status: 'error',
error: message,
});
}
}
};
try {
await Promise.all(
Array.from({ length: Math.min(maxConcurrent, queue.length) }, () =>
worker(),
),
);
if (!signal.aborted) {
onUploadComplete();
if (failedCount > 0) {
toast(
`Batch upload finished: ${queue.length - failedCount}/${queue.length} succeeded`,
{ type: 'warning', position: 'bottom-center' },
);
} else {
toast(`Batch upload finished for ${section.label}`, {
type: 'success',
position: 'bottom-center',
});
}
}
} finally {
markSectionUploading(section.key, false);
}
},
[
selectedProjectId,
addSectionUpload,
markSectionUploading,
uploadAssetFile,
updateSectionUpload,
onUploadComplete,
],
);
return {
uploadingSections,
uploadQueues,
runBatchUpload,
};
}
export default useAssetUploader;

View File

@ -1,533 +0,0 @@
/**
* GenericTable Component
*/
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { ToastContainer, toast } from 'react-toastify';
import {
DataGrid,
GridColDef,
GridSortModel,
GridRowSelectionModel,
} from '@mui/x-data-grid';
import { Field, Form, Formik } from 'formik';
import BaseButton from '../BaseButton';
import CardBoxModal from '../CardBoxModal';
import CardBox from '../CardBox';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import type { RootState } from '../../stores/store';
import type { AsyncThunk } from '@reduxjs/toolkit';
import type { BaseEntity } from '../../types/entities';
import type { FetchParams } from '../../types/api';
import type { NotificationState } from '../../types/redux';
import type { Filter, FilterItem, FilterFields } from '../../types/filters';
import dataFormatter from '../../helpers/dataFormatter';
import { dataGridStyles } from '../../styles';
import _ from 'lodash';
// Entity slice state interface
interface EntitySliceState<T> {
loading: boolean;
count: number;
refetch: boolean;
notify: NotificationState;
[entityName: string]: T[] | boolean | number | NotificationState | unknown;
}
// Props for GenericTable
interface GenericTableProps<T extends BaseEntity> {
entityName: string;
sliceSelector: (state: RootState) => EntitySliceState<T>;
fetchAction: AsyncThunk<unknown, FetchParams, { rejectValue: unknown }>;
updateAction?: AsyncThunk<
unknown,
{ id: string; data: Partial<T> },
{ rejectValue: unknown }
>;
deleteAction: AsyncThunk<unknown, string, { rejectValue: unknown }>;
deleteByIdsAction?: AsyncThunk<unknown, string[], { rejectValue: unknown }>;
setRefetchAction: (value: boolean) => { type: string; payload: boolean };
loadColumnsFunction: (
handleDelete: (id: string) => void,
entityPath: string,
currentUser: unknown,
) => Promise<GridColDef[]>;
filters: Filter[];
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
perPage?: number;
showGrid?: boolean;
}
const GenericTable = <T extends BaseEntity>({
entityName,
sliceSelector,
fetchAction,
updateAction,
deleteAction,
deleteByIdsAction,
setRefetchAction,
loadColumnsFunction,
filters,
filterItems,
setFilterItems,
perPage = 10,
}: GenericTableProps<T>) => {
const notify = (
type: 'success' | 'error' | 'info' | 'warning',
msg: string,
) => toast(msg, { type, position: 'bottom-center' });
const dispatch = useAppDispatch();
const entityState = useAppSelector(sliceSelector);
const { currentUser } = useAppSelector((state) => state.auth);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
const corners = useAppSelector((state) => state.style.corners);
// Extract data from state
const data = (entityState[entityName] as T[]) || [];
const { loading, count, refetch, notify: entityNotify } = entityState;
// Local state
const [currentPage, setCurrentPage] = useState(0);
const [columns, setColumns] = useState<GridColDef[]>([]);
const [filterRequest, setFilterRequest] = useState('');
const [selectedRows, setSelectedRows] = useState<GridRowSelectionModel>([]);
const [sortModel, setSortModel] = useState<GridSortModel>([
{ field: '', sort: 'desc' },
]);
// Delete modal state
const [id, setId] = useState<string | null>(null);
const [isModalTrashActive, setIsModalTrashActive] = useState(false);
// Calculate number of pages
const numPages = useMemo(() => {
return count === 0 ? 1 : Math.ceil(count / perPage);
}, [count, perPage]);
// Load data function
const loadData = useCallback(
async (page = currentPage, request = filterRequest) => {
if (page !== currentPage) setCurrentPage(page);
if (request !== filterRequest) setFilterRequest(request);
const { sort, field } = sortModel[0] || { sort: 'desc', field: '' };
const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`;
dispatch(fetchAction({ query }));
},
[currentPage, filterRequest, sortModel, perPage, dispatch, fetchAction],
);
// Show notifications
useEffect(() => {
if (entityNotify.showNotification) {
notify(
entityNotify.typeNotification as
| 'success'
| 'error'
| 'info'
| 'warning',
entityNotify.textNotification,
);
}
}, [entityNotify.showNotification]);
// Load data on sort model or user change
useEffect(() => {
if (!currentUser) return;
loadData();
}, [sortModel, currentUser]);
// Handle refetch flag
useEffect(() => {
if (refetch) {
loadData(0);
dispatch(setRefetchAction(false));
}
}, [refetch, dispatch, setRefetchAction]);
// Load columns when user is available
useEffect(() => {
if (!currentUser) return;
loadColumnsFunction(handleDeleteModalAction, entityName, currentUser).then(
(newCols) => setColumns(newCols),
);
}, [currentUser, entityName]);
// Modal handlers
const handleModalAction = () => {
setIsModalTrashActive(false);
};
const handleDeleteModalAction = (itemId: string) => {
setId(itemId);
setIsModalTrashActive(true);
};
const handleDeleteAction = async () => {
if (id) {
await dispatch(deleteAction(id));
await loadData(0);
setIsModalTrashActive(false);
}
};
// Generate filter request
const generateFilterRequests = useMemo(() => {
let request = '&';
filterItems.forEach((item) => {
const isRangeFilter = filters.find(
(filter) =>
filter.title === item.fields.selectedField &&
(filter.number || filter.date),
);
if (isRangeFilter) {
const from = item.fields.filterValueFrom;
const to = item.fields.filterValueTo;
if (from) {
request += `${item.fields.selectedField}Range=${from}&`;
}
if (to) {
request += `${item.fields.selectedField}Range=${to}&`;
}
} else {
const value = item.fields.filterValue;
if (value) {
request += `${item.fields.selectedField}=${value}&`;
}
}
});
return request;
}, [filterItems, filters]);
// Filter handlers
const deleteFilter = (value: string) => {
const newItems = filterItems.filter((item) => item.id !== value);
if (newItems.length) {
setFilterItems(newItems);
} else {
loadData(0, '');
setFilterItems(newItems);
}
};
const handleSubmit = () => {
loadData(0, generateFilterRequests);
};
const handleChange =
(itemId: string) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const value = e.target.value;
const name = e.target.name as keyof FilterFields;
setFilterItems(
filterItems.map((item) => {
if (item.id !== itemId) return item;
if (name === 'selectedField')
return {
id: itemId,
fields: {
selectedField: value,
filterValue: '',
filterValueFrom: '',
filterValueTo: '',
},
};
return { id: itemId, fields: { ...item.fields, [name]: value } };
}),
);
};
const handleReset = () => {
setFilterItems([]);
loadData(0, '');
};
const onPageChange = (page: number) => {
loadData(page);
setCurrentPage(page);
};
// Table update handler
const handleTableSubmit = async (
rowId: string,
rowData: Record<string, unknown>,
) => {
if (!_.isEmpty(rowData) && updateAction) {
await dispatch(updateAction({ id: rowId, data: rowData as Partial<T> }))
.unwrap()
.then((res) => res)
.catch((err) => {
throw new Error(err);
});
}
};
// Delete selected rows
const onDeleteRows = async (rows: GridRowSelectionModel) => {
if (deleteByIdsAction) {
await dispatch(deleteByIdsAction(rows as string[]));
await loadData(0);
}
};
const controlClasses =
'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' +
` ${bgColor} ${focusRing} ${corners} ` +
'dark:bg-slate-800 border';
const dataGrid = (
<div className='relative overflow-x-auto'>
<DataGrid
autoHeight
rowHeight={64}
sx={dataGridStyles}
className={'datagrid--table'}
getRowClassName={() => 'datagrid--row'}
rows={data ?? []}
columns={columns}
initialState={{
pagination: {
paginationModel: {
pageSize: 10,
},
},
}}
disableRowSelectionOnClick
onProcessRowUpdateError={(params) => {
console.log('Error', params);
}}
processRowUpdate={async (newRow, oldRow) => {
const formattedData = dataFormatter.dataGridEditFormatter(newRow);
try {
await handleTableSubmit(newRow.id, formattedData);
return newRow;
} catch {
return oldRow;
}
}}
sortingMode={'server'}
checkboxSelection
onRowSelectionModelChange={(ids) => {
setSelectedRows(ids);
}}
onSortModelChange={(params) => {
params.length
? setSortModel(params)
: setSortModel([{ field: '', sort: 'desc' }]);
}}
rowCount={count}
pageSizeOptions={[10]}
paginationMode={'server'}
loading={loading}
onPaginationModelChange={(params) => {
onPageChange(params.page);
}}
/>
</div>
);
return (
<>
{filterItems && Array.isArray(filterItems) && filterItems.length ? (
<CardBox>
<Formik
initialValues={{
checkboxes: ['lorem'],
switches: ['lorem'],
radio: 'lorem',
}}
onSubmit={() => null}
>
<Form>
<>
{filterItems.map((filterItem) => {
const selectedFilter = filters.find(
(filter) =>
filter.title === filterItem?.fields?.selectedField,
);
return (
<div key={filterItem.id} className='flex mb-4'>
<div className='flex flex-col w-full mr-3'>
<div className='text-gray-500 font-bold'>Filter</div>
<Field
className={controlClasses}
name='selectedField'
id='selectedField'
component='select'
value={filterItem?.fields?.selectedField || ''}
onChange={handleChange(filterItem.id)}
>
{filters.map((selectOption) => (
<option
key={selectOption.title}
value={selectOption.title}
>
{selectOption.label}
</option>
))}
</Field>
</div>
{selectedFilter?.type === 'enum' ? (
<div className='flex flex-col w-full mr-3'>
<div className='text-gray-500 font-bold'>Value</div>
<Field
className={controlClasses}
name='filterValue'
id='filterValue'
component='select'
value={filterItem?.fields?.filterValue || ''}
onChange={handleChange(filterItem.id)}
>
<option value=''>Select Value</option>
{selectedFilter?.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Field>
</div>
) : selectedFilter?.number ? (
<div className='flex flex-row w-full mr-3'>
<div className='flex flex-col w-full mr-3'>
<div className='text-gray-500 font-bold'>From</div>
<Field
className={controlClasses}
name='filterValueFrom'
placeholder='From'
id='filterValueFrom'
value={filterItem?.fields?.filterValueFrom || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
<div className='flex flex-col w-full'>
<div className='text-gray-500 font-bold'>To</div>
<Field
className={controlClasses}
name='filterValueTo'
placeholder='to'
id='filterValueTo'
value={filterItem?.fields?.filterValueTo || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
</div>
) : selectedFilter?.date ? (
<div className='flex flex-row w-full mr-3'>
<div className='flex flex-col w-full mr-3'>
<div className='text-gray-500 font-bold'>From</div>
<Field
className={controlClasses}
name='filterValueFrom'
placeholder='From'
id='filterValueFrom'
type='datetime-local'
value={filterItem?.fields?.filterValueFrom || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
<div className='flex flex-col w-full'>
<div className='text-gray-500 font-bold'>To</div>
<Field
className={controlClasses}
name='filterValueTo'
placeholder='to'
id='filterValueTo'
type='datetime-local'
value={filterItem?.fields?.filterValueTo || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
</div>
) : (
<div className='flex flex-col w-full mr-3'>
<div className='text-gray-500 font-bold'>
Contains
</div>
<Field
className={controlClasses}
name='filterValue'
placeholder='Contained'
id='filterValue'
value={filterItem?.fields?.filterValue || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
)}
<div className='flex flex-col'>
<div className='text-gray-500 font-bold'>Action</div>
<BaseButton
className='my-2'
type='reset'
color='danger'
label='Delete'
onClick={() => {
deleteFilter(filterItem.id);
}}
/>
</div>
</div>
);
})}
<div className='flex'>
<BaseButton
className='my-2 mr-3'
color='success'
label='Apply'
onClick={handleSubmit}
/>
<BaseButton
className='my-2'
color='info'
label='Cancel'
onClick={handleReset}
/>
</div>
</>
</Form>
</Formik>
</CardBox>
) : null}
<CardBoxModal
title='Please confirm'
buttonColor='info'
buttonLabel={loading ? 'Deleting...' : 'Confirm'}
isActive={isModalTrashActive}
onConfirm={handleDeleteAction}
onCancel={handleModalAction}
>
<p>Are you sure you want to delete this item?</p>
</CardBoxModal>
{dataGrid}
{selectedRows.length > 0 &&
typeof document !== 'undefined' &&
document.getElementById('delete-rows-button') &&
createPortal(
<BaseButton
className='me-4'
color='danger'
label={`Delete ${selectedRows.length === 1 ? 'Row' : 'Rows'}`}
onClick={() => onDeleteRows(selectedRows)}
/>,
document.getElementById('delete-rows-button')!,
)}
<ToastContainer />
</>
);
};
export default GenericTable;

View File

@ -5,7 +5,7 @@ import axios from 'axios';
import { import {
GridActionsCellItem, GridActionsCellItem,
GridRowParams, GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';
@ -54,8 +54,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('tour_pages'), valueOptions: await callOptionsApi('tour_pages'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {

View File

@ -5,7 +5,7 @@ import axios from 'axios';
import { import {
GridActionsCellItem, GridActionsCellItem,
GridRowParams, GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';
@ -54,8 +54,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('tour_pages'), valueOptions: await callOptionsApi('tour_pages'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {
@ -74,8 +73,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('tour_pages'), valueOptions: await callOptionsApi('tour_pages'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {
@ -118,8 +116,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('transitions'), valueOptions: await callOptionsApi('transitions'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {

View File

@ -5,7 +5,7 @@ import axios from 'axios';
import { import {
GridActionsCellItem, GridActionsCellItem,
GridRowParams, GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';

View File

@ -5,7 +5,7 @@ import axios from 'axios';
import { import {
GridActionsCellItem, GridActionsCellItem,
GridRowParams, GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';
@ -57,8 +57,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'), valueOptions: await callOptionsApi('projects'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {
@ -77,8 +76,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('users'), valueOptions: await callOptionsApi('users'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {
@ -155,8 +153,7 @@ export const loadColumns = async (
editable: hasUpdatePermission, editable: hasUpdatePermission,
type: 'dateTime', type: 'dateTime',
valueGetter: (params: GridValueGetterParams) => valueGetter: (_value, row) => new Date(row.expires_at),
new Date(params.row.expires_at),
}, },
{ {

View File

@ -5,7 +5,7 @@ import axios from 'axios';
import { import {
GridActionsCellItem, GridActionsCellItem,
GridRowParams, GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';
@ -57,8 +57,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'), valueOptions: await callOptionsApi('projects'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {

View File

@ -5,7 +5,7 @@ import axios from 'axios';
import { import {
GridActionsCellItem, GridActionsCellItem,
GridRowParams, GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';
@ -54,8 +54,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'), valueOptions: await callOptionsApi('projects'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {
@ -74,8 +73,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('users'), valueOptions: await callOptionsApi('users'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {
@ -116,8 +114,7 @@ export const loadColumns = async (
editable: hasUpdatePermission, editable: hasUpdatePermission,
type: 'dateTime', type: 'dateTime',
valueGetter: (params: GridValueGetterParams) => valueGetter: (_value, row) => new Date(row.invited_at),
new Date(params.row.invited_at),
}, },
{ {
@ -132,8 +129,7 @@ export const loadColumns = async (
editable: hasUpdatePermission, editable: hasUpdatePermission,
type: 'dateTime', type: 'dateTime',
valueGetter: (params: GridValueGetterParams) => valueGetter: (_value, row) => new Date(row.accepted_at),
new Date(params.row.accepted_at),
}, },
{ {

View File

@ -5,7 +5,7 @@ import axios from 'axios';
import { import {
GridActionsCellItem, GridActionsCellItem,
GridRowParams, GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';
@ -196,8 +196,7 @@ export const loadColumns = async (
editable: hasUpdatePermission, editable: hasUpdatePermission,
type: 'dateTime', type: 'dateTime',
valueGetter: (params: GridValueGetterParams) => valueGetter: (_value, row) => new Date(row.deleted_at_time),
new Date(params.row.deleted_at_time),
}, },
{ {

View File

@ -5,7 +5,7 @@ import axios from 'axios';
import { import {
GridActionsCellItem, GridActionsCellItem,
GridRowParams, GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';
@ -54,8 +54,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'), valueOptions: await callOptionsApi('projects'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {
@ -74,8 +73,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('users'), valueOptions: await callOptionsApi('users'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {
@ -114,8 +112,7 @@ export const loadColumns = async (
editable: hasUpdatePermission, editable: hasUpdatePermission,
type: 'dateTime', type: 'dateTime',
valueGetter: (params: GridValueGetterParams) => valueGetter: (_value, row) => new Date(row.started_at),
new Date(params.row.started_at),
}, },
{ {
@ -130,8 +127,7 @@ export const loadColumns = async (
editable: hasUpdatePermission, editable: hasUpdatePermission,
type: 'dateTime', type: 'dateTime',
valueGetter: (params: GridValueGetterParams) => valueGetter: (_value, row) => new Date(row.finished_at),
new Date(params.row.finished_at),
}, },
{ {

View File

@ -5,7 +5,7 @@ import axios from 'axios';
import { import {
GridActionsCellItem, GridActionsCellItem,
GridRowParams, GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';
@ -54,8 +54,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'), valueOptions: await callOptionsApi('projects'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {
@ -118,8 +117,7 @@ export const loadColumns = async (
editable: hasUpdatePermission, editable: hasUpdatePermission,
type: 'dateTime', type: 'dateTime',
valueGetter: (params: GridValueGetterParams) => valueGetter: (_value, row) => new Date(row.generated_at),
new Date(params.row.generated_at),
}, },
{ {

View File

@ -5,7 +5,7 @@ import axios from 'axios';
import { import {
GridActionsCellItem, GridActionsCellItem,
GridRowParams, GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';

View File

@ -5,7 +5,7 @@ import axios from 'axios';
import { import {
GridActionsCellItem, GridActionsCellItem,
GridRowParams, GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';
@ -54,8 +54,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'), valueOptions: await callOptionsApi('projects'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {

View File

@ -5,7 +5,7 @@ import axios from 'axios';
import { import {
GridActionsCellItem, GridActionsCellItem,
GridRowParams, GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';
@ -54,8 +54,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'), valueOptions: await callOptionsApi('projects'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {

View File

@ -75,19 +75,28 @@ export default class FileUploader {
typeof options.onProgress === 'function' ? options.onProgress : null; typeof options.onProgress === 'function' ? options.onProgress : null;
const onStatus = const onStatus =
typeof options.onStatus === 'function' ? options.onStatus : null; typeof options.onStatus === 'function' ? options.onStatus : null;
const signal = options.signal || null;
const extension = extractExtensionFrom(file.name); const extension = extractExtensionFrom(file.name);
const id = uuidv4(); const id = uuidv4();
const filename = extension ? `${id}.${extension}` : id; const filename = extension ? `${id}.${extension}` : id;
const privateUrl = `${path}/${filename}`; const privateUrl = `${path}/${filename}`;
const totalChunks = Math.max(1, Math.ceil(file.size / chunkSize)); const totalChunks = Math.max(1, Math.ceil(file.size / chunkSize));
const initResponse = await Axios.post('/file/upload-sessions/init', { if (signal?.aborted) {
folder: path, throw new Error('Upload aborted');
filename, }
size: file.size,
contentType: file.type || '', const initResponse = await Axios.post(
totalChunks, '/file/upload-sessions/init',
}); {
folder: path,
filename,
size: file.size,
contentType: file.type || '',
totalChunks,
},
{ signal },
);
const sessionId = initResponse?.data?.sessionId; const sessionId = initResponse?.data?.sessionId;
@ -96,6 +105,10 @@ export default class FileUploader {
} }
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) {
if (signal?.aborted) {
throw new Error('Upload aborted');
}
const start = chunkIndex * chunkSize; const start = chunkIndex * chunkSize;
const end = Math.min(file.size, start + chunkSize); const end = Math.min(file.size, start + chunkSize);
const chunk = file.slice(start, end); const chunk = file.slice(start, end);
@ -118,10 +131,14 @@ export default class FileUploader {
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
}, },
signal,
}, },
); );
break; break;
} catch (error) { } catch (error) {
if (signal?.aborted) {
throw new Error('Upload aborted');
}
retry += 1; retry += 1;
if (retry > maxRetries) { if (retry > maxRetries) {
throw error; throw error;
@ -139,12 +156,18 @@ export default class FileUploader {
} }
} }
if (signal?.aborted) {
throw new Error('Upload aborted');
}
if (onStatus) { if (onStatus) {
onStatus('finalizing', null); onStatus('finalizing', null);
} }
const finalizeResponse = await Axios.post( const finalizeResponse = await Axios.post(
`/file/upload-sessions/${sessionId}/finalize`, `/file/upload-sessions/${sessionId}/finalize`,
null,
{ signal },
); );
const responsePublicUrl = finalizeResponse?.data?.url; const responsePublicUrl = finalizeResponse?.data?.url;
const publicUrl = responsePublicUrl const publicUrl = responsePublicUrl

View File

@ -5,7 +5,7 @@ import axios from 'axios';
import { import {
GridActionsCellItem, GridActionsCellItem,
GridRowParams, GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import ImageField from '../ImageField'; import ImageField from '../ImageField';
import { saveFile } from '../../helpers/fileSaver'; import { saveFile } from '../../helpers/fileSaver';
@ -111,7 +111,7 @@ export const loadColumns = async (
editable: false, editable: false,
sortable: false, sortable: false,
renderCell: (params: GridValueGetterParams) => ( renderCell: (value) => (
<ImageField <ImageField
name={'Avatar'} name={'Avatar'}
image={params?.row?.avatar} image={params?.row?.avatar}
@ -136,8 +136,7 @@ export const loadColumns = async (
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label, getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('roles'), valueOptions: await callOptionsApi('roles'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (value) => value?.id ?? value,
params?.value?.id ?? params?.value,
}, },
{ {

View File

@ -1,64 +1,17 @@
import { mdiChartTimelineVariant } from '@mdi/js'; import { mdiChartTimelineVariant } from '@mdi/js';
import axios from 'axios';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import { toast, ToastContainer } from 'react-toastify'; import { toast, ToastContainer } from 'react-toastify';
import BaseButton from '../../components/BaseButton';
import CardBox from '../../components/CardBox';
import FileUploader from '../../components/Uploaders/UploadService';
import LayoutAuthenticated from '../../layouts/Authenticated'; import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain'; import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config'; import { getPageTitle } from '../../config';
import { hasPermission } from '../../helpers/userPermissions'; import { hasPermission } from '../../helpers/userPermissions';
import { useAppSelector } from '../../stores/hooks'; import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { fetch as fetchAssets, deleteItem as deleteAsset } from '../../stores/assets/assetsSlice';
type Project = { import AssetSectionCard, { Asset, AssetSection } from '../../components/Assets/AssetSectionCard';
id: string; import { useProjectSelector } from '../../components/Assets/ProjectSelector';
name: string; import { useAssetUploader } from '../../components/Assets/useAssetUploader';
};
type Asset = {
id: string;
name: string;
asset_type: 'image' | 'video' | 'audio' | 'file';
type?:
| 'icon'
| 'background_image'
| 'audio'
| 'video'
| 'transition'
| 'logo'
| 'favicon'
| 'document'
| 'general';
cdn_url?: string | null;
mime_type?: string | null;
};
type AssetSection = {
key:
| 'images'
| 'backgroundImages'
| 'audio'
| 'video'
| 'transitions'
| 'logo';
label: string;
accept: string;
assetFormat: 'image' | 'video' | 'audio';
assetCategory: NonNullable<Asset['type']>;
legacyTag: string;
};
type UploadQueueItem = {
id: string;
fileName: string;
progress: number;
status: 'queued' | 'uploading' | 'saving' | 'success' | 'error';
error?: string;
};
const ASSET_SECTIONS: AssetSection[] = [ const ASSET_SECTIONS: AssetSection[] = [
{ {
@ -112,29 +65,39 @@ const ASSET_SECTIONS: AssetSection[] = [
]; ];
const AssetsTablesPage = () => { const AssetsTablesPage = () => {
const router = useRouter(); const dispatch = useAppDispatch();
const routeProjectId = useMemo(() => {
const value = router.query.projectId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.projectId]);
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const assets = useAppSelector((state) => state.assets.assets) as Asset[];
const isLoadingAssets = useAppSelector((state) => state.assets.loading);
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState('');
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [assets, setAssets] = useState<Asset[]>([]);
const [isLoadingAssets, setIsLoadingAssets] = useState(false);
const [uploadingSections, setUploadingSections] = useState<string[]>([]);
const [uploadQueues, setUploadQueues] = useState<
Record<string, UploadQueueItem[]>
>({});
const [expandedUploadedLists, setExpandedUploadedLists] = useState<
Record<string, boolean>
>({});
const [deletingAssetId, setDeletingAssetId] = useState<string>(''); const [deletingAssetId, setDeletingAssetId] = useState<string>('');
const {
selectedProjectId,
isLoadingProjects,
selectedProjectName,
} = useProjectSelector({ currentUser });
const loadAssets = useCallback((projectId: string) => {
if (!projectId) return;
dispatch(fetchAssets({
query: `?limit=500&page=0&sort=desc&field=createdAt&project=${projectId}`,
}));
}, [dispatch]);
const {
uploadingSections,
uploadQueues,
runBatchUpload,
} = useAssetUploader({
selectedProjectId,
onUploadComplete: () => loadAssets(selectedProjectId),
});
useEffect(() => {
loadAssets(selectedProjectId);
}, [selectedProjectId, loadAssets]);
const hasCreatePermission = Boolean( const hasCreatePermission = Boolean(
currentUser && hasPermission(currentUser, 'CREATE_ASSETS'), currentUser && hasPermission(currentUser, 'CREATE_ASSETS'),
); );
@ -142,286 +105,16 @@ const AssetsTablesPage = () => {
currentUser && hasPermission(currentUser, 'DELETE_ASSETS'), currentUser && hasPermission(currentUser, 'DELETE_ASSETS'),
); );
const loadProjects = async () => { const handleDeleteAsset = useCallback(async (assetId: string) => {
setIsLoadingProjects(true);
try {
let rows: Project[] = [];
const response = await axios.get(
'/projects?limit=100&page=0&sort=desc&field=updatedAt',
);
rows = Array.isArray(response?.data?.rows) ? response.data.rows : [];
if (rows.length === 0) {
const autocompleteResponse = await axios.get(
'/projects/autocomplete?limit=100',
);
const autocompleteItems = Array.isArray(autocompleteResponse?.data)
? autocompleteResponse.data
: [];
rows = autocompleteItems.map((item: { id: string; label: string }) => ({
id: item.id,
name: item.label,
}));
}
// Redirect to projects page if no projects exist
if (rows.length === 0) {
toast('Please create a project first', {
type: 'info',
position: 'bottom-center',
});
router.replace('/projects/projects-new');
return;
}
setProjects(rows);
if (
routeProjectId &&
rows.some((project) => project.id === routeProjectId)
) {
setSelectedProjectId(routeProjectId);
} else {
setSelectedProjectId((prev) => {
if (rows.some((project) => project.id === prev)) return prev;
return rows[0]?.id || '';
});
}
} catch (error: any) {
console.error('Failed to load projects:', error?.message || error);
setProjects([]);
setSelectedProjectId('');
toast('Failed to load projects', {
type: 'error',
position: 'bottom-center',
});
} finally {
setIsLoadingProjects(false);
}
};
const loadAssets = async (projectId: string) => {
if (!projectId) {
setAssets([]);
return;
}
setIsLoadingAssets(true);
try {
const response = await axios.get(
`/assets?limit=500&page=0&sort=desc&field=createdAt&project=${projectId}`,
);
const rows = Array.isArray(response?.data?.rows)
? response.data.rows
: [];
setAssets(rows);
} catch (error: any) {
console.error('Failed to load assets:', error?.message || error);
toast('Failed to load assets', {
type: 'error',
position: 'bottom-center',
});
} finally {
setIsLoadingAssets(false);
}
};
useEffect(() => {
// Wait for auth to be established before loading data
if (!currentUser) return;
loadProjects();
}, [routeProjectId, currentUser]);
useEffect(() => {
loadAssets(selectedProjectId);
}, [selectedProjectId]);
const addSectionUpload = (sectionKey: string, items: UploadQueueItem[]) => {
setUploadQueues((prev) => ({
...prev,
[sectionKey]: [...(prev[sectionKey] || []), ...items],
}));
};
const updateSectionUpload = (
sectionKey: string,
itemId: string,
patch: Partial<UploadQueueItem>,
) => {
setUploadQueues((prev) => ({
...prev,
[sectionKey]: (prev[sectionKey] || []).map((item) =>
item.id === itemId ? { ...item, ...patch } : item,
),
}));
};
const markSectionUploading = (sectionKey: string, isUploading: boolean) => {
setUploadingSections((prev) => {
if (isUploading) {
if (prev.includes(sectionKey)) {
return prev;
}
return [...prev, sectionKey];
}
return prev.filter((key) => key !== sectionKey);
});
};
const toggleUploadedList = (sectionKey: string) => {
setExpandedUploadedLists((prev) => ({
...prev,
[sectionKey]: !(prev[sectionKey] ?? false),
}));
};
const uploadAssetFile = async (
section: AssetSection,
projectId: string,
itemId: string,
file: File,
) => {
updateSectionUpload(section.key, itemId, {
status: 'uploading',
progress: 0,
});
const remoteFile = await FileUploader.uploadChunked(
`assets/${projectId}`,
file,
{},
{
chunkSize: 5 * 1024 * 1024,
maxRetries: 3,
onProgress: (progress: number) => {
updateSectionUpload(section.key, itemId, {
progress,
status: 'uploading',
});
},
onStatus: (status: string) => {
if (status === 'finalizing') {
updateSectionUpload(section.key, itemId, { status: 'saving' });
}
},
},
);
await axios.post('/assets', {
data: {
project: projectId,
name: file.name,
asset_type: section.assetFormat,
type: section.assetCategory,
cdn_url: remoteFile.publicUrl,
storage_key: remoteFile.privateUrl,
mime_type: file.type || null,
size_mb: Number((file.size / (1024 * 1024)).toFixed(4)),
is_public: false,
is_deleted: false,
},
});
updateSectionUpload(section.key, itemId, {
status: 'success',
progress: 100,
});
};
const runBatchUpload = async (section: AssetSection, files: File[]) => {
if (!selectedProjectId) {
toast('Select a project first', {
type: 'warning',
position: 'bottom-center',
});
return;
}
if (!files.length) {
return;
}
const queueItems: UploadQueueItem[] = files.map((file) => ({
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
fileName: file.name,
progress: 0,
status: 'queued',
}));
addSectionUpload(section.key, queueItems);
markSectionUploading(section.key, true);
const queue = files.map((file, index) => ({
file,
itemId: queueItems[index].id,
}));
const maxConcurrent = 2;
let currentIndex = 0;
let failedCount = 0;
const worker = async () => {
while (currentIndex < queue.length) {
const nextIndex = currentIndex;
currentIndex += 1;
const item = queue[nextIndex];
try {
await uploadAssetFile(
section,
selectedProjectId,
item.itemId,
item.file,
);
} catch (error: any) {
const message =
error?.response?.data?.message || error?.message || 'Upload failed';
console.error(`Failed to upload ${item.file.name}:`, error);
failedCount += 1;
updateSectionUpload(section.key, item.itemId, {
status: 'error',
error: message,
});
}
}
};
try {
await Promise.all(
Array.from({ length: Math.min(maxConcurrent, queue.length) }, () =>
worker(),
),
);
await loadAssets(selectedProjectId);
if (failedCount > 0) {
toast(
`Batch upload finished: ${queue.length - failedCount}/${queue.length} succeeded`,
{
type: 'warning',
position: 'bottom-center',
},
);
} else {
toast(`Batch upload finished for ${section.label}`, {
type: 'success',
position: 'bottom-center',
});
}
} finally {
markSectionUploading(section.key, false);
}
};
const handleDeleteAsset = async (assetId: string) => {
setDeletingAssetId(assetId); setDeletingAssetId(assetId);
try { try {
await axios.delete(`/assets/${assetId}`); await dispatch(deleteAsset(assetId)).unwrap();
toast('Asset deleted', { type: 'success', position: 'bottom-center' }); toast('Asset deleted', { type: 'success', position: 'bottom-center' });
await loadAssets(selectedProjectId); loadAssets(selectedProjectId);
} catch (error: any) { } catch (error: unknown) {
console.error('Failed to delete asset:', error?.message || error); const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Failed to delete asset:', errorMessage);
toast('Failed to delete asset', { toast('Failed to delete asset', {
type: 'error', type: 'error',
position: 'bottom-center', position: 'bottom-center',
@ -429,7 +122,7 @@ const AssetsTablesPage = () => {
} finally { } finally {
setDeletingAssetId(''); setDeletingAssetId('');
} }
}; }, [dispatch, selectedProjectId, loadAssets]);
const assetsBySection = useMemo(() => { const assetsBySection = useMemo(() => {
return ASSET_SECTIONS.reduce<Record<string, Asset[]>>((acc, section) => { return ASSET_SECTIONS.reduce<Record<string, Asset[]>>((acc, section) => {
@ -458,143 +151,26 @@ const AssetsTablesPage = () => {
<p className='mb-6 text-sm font-semibold'> <p className='mb-6 text-sm font-semibold'>
{isLoadingProjects {isLoadingProjects
? 'Loading project...' ? 'Loading project...'
: projects.find((project) => project.id === selectedProjectId) : selectedProjectName || 'No project selected'}
?.name || 'No project selected'}
</p> </p>
<div className='grid grid-cols-1 xl:grid-cols-2 gap-4'> <div className='grid grid-cols-1 xl:grid-cols-2 gap-4'>
{ASSET_SECTIONS.map((section) => { {ASSET_SECTIONS.map((section) => (
const list = assetsBySection[section.key] || []; <AssetSectionCard
const isUploadedListExpanded = key={section.key}
expandedUploadedLists[section.key] ?? false; section={section}
assets={assetsBySection[section.key] || []}
return ( uploadQueue={uploadQueues[section.key] || []}
<CardBox key={section.key} className='h-full'> isUploading={uploadingSections.includes(section.key)}
<div className='flex items-center justify-between gap-3 mb-4'> isLoadingAssets={isLoadingAssets}
<h3 className='text-lg font-semibold'>{section.label}</h3> hasCreatePermission={hasCreatePermission}
{!hasCreatePermission && ( hasDeletePermission={hasDeletePermission}
<p className='text-xs text-gray-500'> deletingAssetId={deletingAssetId}
You do not have upload permission onUpload={(files) => runBatchUpload(section, files)}
</p> onDeleteAsset={handleDeleteAsset}
)} disabled={!selectedProjectId}
</div> />
<input ))}
type='file'
multiple
accept={section.accept}
className='w-full border border-gray-300 rounded px-2 py-2 mb-3 bg-white dark:bg-dark-800'
disabled={
uploadingSections.includes(section.key) ||
!selectedProjectId ||
!hasCreatePermission
}
onChange={(event) => {
const files = Array.from(event.target.files || []);
void runBatchUpload(section, files);
event.currentTarget.value = '';
}}
/>
{uploadingSections.includes(section.key) && (
<p className='text-sm text-gray-500 mb-3'>
Uploading {section.label.toLowerCase()} in chunks...
</p>
)}
{(uploadQueues[section.key] || []).filter(
(item) => item.status !== 'success',
).length > 0 && (
<ul className='space-y-1 mb-3 max-h-40 overflow-auto'>
{(uploadQueues[section.key] || [])
.filter((item) => item.status !== 'success')
.map((item) => (
<li
key={item.id}
className='text-xs border border-gray-200 dark:border-dark-700 rounded p-2'
>
<div className='flex items-center justify-between gap-2'>
<span className='truncate'>{item.fileName}</span>
<span>
{item.status === 'error'
? 'Error'
: item.status === 'success'
? 'Done'
: item.status}
</span>
</div>
{item.status !== 'success' && (
<div className='w-full h-1 bg-gray-200 rounded mt-1'>
<div
className='h-1 bg-blue-500 rounded'
style={{ width: `${item.progress}%` }}
/>
</div>
)}
{item.error && (
<p className='text-red-500 mt-1 truncate'>
{item.error}
</p>
)}
</li>
))}
</ul>
)}
{isLoadingAssets && (
<p className='text-sm text-gray-500'>Loading assets...</p>
)}
{!isLoadingAssets && list.length > 0 && (
<button
type='button'
className='mb-2 text-xs underline'
onClick={() => toggleUploadedList(section.key)}
>
{isUploadedListExpanded
? `Hide uploaded ${section.label.toLowerCase()}`
: `Show uploaded ${section.label.toLowerCase()} (${list.length})`}
</button>
)}
{!isLoadingAssets && list.length === 0 && (
<p className='text-sm text-gray-500'>
No uploaded {section.label.toLowerCase()}.
</p>
)}
{!isLoadingAssets &&
list.length > 0 &&
isUploadedListExpanded && (
<ul className='space-y-2'>
{list.map((asset) => (
<li
key={asset.id}
className='flex items-center justify-between gap-2 p-2 border border-gray-200 dark:border-dark-700 rounded'
>
<a
href={asset.cdn_url || '#'}
target='_blank'
rel='noreferrer'
className='text-sm underline truncate'
>
{asset.name.replace(/^\[[^\]]+\]\s*/, '')}
</a>
<BaseButton
color='danger'
label='X'
small
disabled={
deletingAssetId === asset.id ||
!hasDeletePermission
}
onClick={() => handleDeleteAsset(asset.id)}
/>
</li>
))}
</ul>
)}
</CardBox>
);
})}
</div> </div>
</SectionMain> </SectionMain>
<ToastContainer /> <ToastContainer />

View File

@ -10,54 +10,31 @@ import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain'; import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config'; import { getPageTitle } from '../../config';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
type ProjectItem = { import { fetch as fetchProjects, create as createProject } from '../../stores/projects/projectsSlice';
id: string; import type { Project } from '../../types/entities';
name: string;
slug: string;
phase: string;
description?: string | null;
};
const ProjectsListPage = () => { const ProjectsListPage = () => {
const router = useRouter(); const router = useRouter();
const [projects, setProjects] = useState<ProjectItem[]>([]); const dispatch = useAppDispatch();
const [isLoading, setIsLoading] = useState(false);
const projects = useAppSelector((state) => state.projects.projects) as Project[];
const isLoading = useAppSelector((state) => state.projects.loading);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [isCloning, setIsCloning] = useState(false); const [isCloning, setIsCloning] = useState(false);
const [isCloneOpen, setIsCloneOpen] = useState(false); const [isCloneOpen, setIsCloneOpen] = useState(false);
const [cloneSourceId, setCloneSourceId] = useState(''); const [cloneSourceId, setCloneSourceId] = useState('');
const loadProjects = async () => { useEffect(() => {
setIsLoading(true); dispatch(fetchProjects({ query: '?limit=100&page=0&sort=desc&field=updatedAt' }));
try { }, [dispatch]);
const response = await axios.get(
'/projects?limit=100&page=0&sort=desc&field=updatedAt',
);
const rows = Array.isArray(response?.data?.rows)
? response.data.rows
: [];
setProjects(rows);
if (rows.length > 0 && !cloneSourceId) {
setCloneSourceId(rows[0].id);
}
} catch (error: any) {
console.error(
'Failed to load projects list:',
error?.message || 'Unknown error',
);
toast('Failed to load projects', {
type: 'error',
position: 'bottom-center',
});
} finally {
setIsLoading(false);
}
};
useEffect(() => { useEffect(() => {
loadProjects(); if (projects.length > 0 && !cloneSourceId) {
}, []); setCloneSourceId(projects[0].id);
}
}, [projects, cloneSourceId]);
const buildNewProjectDraft = () => { const buildNewProjectDraft = () => {
const stamp = Date.now(); const stamp = Date.now();
@ -76,21 +53,17 @@ const ProjectsListPage = () => {
const handleCreateNewProject = async () => { const handleCreateNewProject = async () => {
setIsCreating(true); setIsCreating(true);
try { try {
const response = await axios.post('/projects', { const result = await dispatch(createProject(buildNewProjectDraft())).unwrap();
data: buildNewProjectDraft(), const createdId = result?.id;
});
const createdId = response?.data?.id;
if (!createdId) { if (!createdId) {
throw new Error('Project was created but id is missing in response'); throw new Error('Project was created but id is missing in response');
} }
await router.push(`/projects/${createdId}`); await router.push(`/projects/${createdId}`);
} catch (error: any) { } catch (error: unknown) {
console.error( const errorMessage = error instanceof Error ? error.message : 'Unknown error';
'Failed to create project:', console.error('Failed to create project:', errorMessage);
error?.message || 'Unknown error',
);
toast('Failed to create project', { toast('Failed to create project', {
type: 'error', type: 'error',
position: 'bottom-center', position: 'bottom-center',
@ -119,11 +92,9 @@ const ProjectsListPage = () => {
} }
await router.push(`/projects/${createdId}`); await router.push(`/projects/${createdId}`);
} catch (error: any) { } catch (error: unknown) {
console.error( const errorMessage = error instanceof Error ? error.message : 'Unknown error';
'Failed to clone project:', console.error('Failed to clone project:', errorMessage);
error?.message || 'Unknown error',
);
toast('Failed to clone project', { toast('Failed to clone project', {
type: 'error', type: 'error',
position: 'bottom-center', position: 'bottom-center',