diff --git a/backend/src/db/migrations/20260618000000-create-vorta-commerce-states.js b/backend/src/db/migrations/20260618000000-create-vorta-commerce-states.js new file mode 100644 index 0000000..252d95c --- /dev/null +++ b/backend/src/db/migrations/20260618000000-create-vorta-commerce-states.js @@ -0,0 +1,133 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tables = await queryInterface.showAllTables({ transaction }); + const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table)); + + if (!tableNames.includes('vorta_commerce_states')) { + await queryInterface.createTable( + 'vorta_commerce_states', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + userId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + storeId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'stores', + key: 'id', + }, + }, + organizationsId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'organizations', + key: 'id', + }, + }, + settings: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + campaigns: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + ledger: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + product_meta: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + customer_meta: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + order_meta: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + shipment_meta: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + }, + }, + { transaction }, + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + console.error('Failed to create vorta_commerce_states table:', error); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tables = await queryInterface.showAllTables({ transaction }); + const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table)); + + if (tableNames.includes('vorta_commerce_states')) { + await queryInterface.dropTable('vorta_commerce_states', { transaction }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + console.error('Failed to drop vorta_commerce_states table:', error); + throw error; + } + }, +}; diff --git a/backend/src/db/migrations/20260618001000-create-vorta-universe-states.js b/backend/src/db/migrations/20260618001000-create-vorta-universe-states.js new file mode 100644 index 0000000..60aa8d0 --- /dev/null +++ b/backend/src/db/migrations/20260618001000-create-vorta-universe-states.js @@ -0,0 +1,116 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tables = await queryInterface.showAllTables({ transaction }); + const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table)); + + if (!tableNames.includes('vorta_universe_states')) { + await queryInterface.createTable( + 'vorta_universe_states', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + userId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + organizationsId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'organizations', + key: 'id', + }, + }, + profile: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + modules: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + products: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + ledger: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + settings: { + type: Sequelize.DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + }, + }, + { transaction }, + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + console.error('Failed to create vorta_universe_states table:', error); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tables = await queryInterface.showAllTables({ transaction }); + const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table)); + + if (tableNames.includes('vorta_universe_states')) { + await queryInterface.dropTable('vorta_universe_states', { transaction }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + console.error('Failed to drop vorta_universe_states table:', error); + throw error; + } + }, +}; diff --git a/backend/src/db/migrations/20260618002000-create-vorta-login-otps.js b/backend/src/db/migrations/20260618002000-create-vorta-login-otps.js new file mode 100644 index 0000000..50dc91f --- /dev/null +++ b/backend/src/db/migrations/20260618002000-create-vorta-login-otps.js @@ -0,0 +1,116 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tables = await queryInterface.showAllTables({ transaction }); + const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table)); + + if (!tableNames.includes('vorta_login_otps')) { + await queryInterface.createTable( + 'vorta_login_otps', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + userId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + organizationsId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'organizations', + key: 'id', + }, + }, + phone: { + type: Sequelize.DataTypes.STRING(40), + allowNull: false, + }, + otpHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: false, + }, + status: { + type: Sequelize.DataTypes.STRING(40), + allowNull: false, + defaultValue: 'pending', + }, + attempts: { + type: Sequelize.DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + expiresAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + verifiedAt: { + type: Sequelize.DataTypes.DATE, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + }, + }, + { transaction }, + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + console.error('Failed to create vorta_login_otps table:', error); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tables = await queryInterface.showAllTables({ transaction }); + const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table)); + + if (tableNames.includes('vorta_login_otps')) { + await queryInterface.dropTable('vorta_login_otps', { transaction }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + console.error('Failed to drop vorta_login_otps table:', error); + throw error; + } + }, +}; diff --git a/backend/src/db/migrations/20260618002100-create-vorta-social-feed.js b/backend/src/db/migrations/20260618002100-create-vorta-social-feed.js new file mode 100644 index 0000000..33e7416 --- /dev/null +++ b/backend/src/db/migrations/20260618002100-create-vorta-social-feed.js @@ -0,0 +1,144 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tables = await queryInterface.showAllTables({ transaction }); + const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table)); + + if (!tableNames.includes('vorta_social_posts')) { + await queryInterface.createTable( + 'vorta_social_posts', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + userId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + organizationsId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'organizations', + key: 'id', + }, + }, + user: { + type: Sequelize.DataTypes.STRING(255), + allowNull: false, + }, + content: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + created_at: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + } + + if (!tableNames.includes('vorta_social_comments')) { + await queryInterface.createTable( + 'vorta_social_comments', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + post_id: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + model: 'vorta_social_posts', + key: 'id', + }, + }, + userId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + organizationsId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'organizations', + key: 'id', + }, + }, + user: { + type: Sequelize.DataTypes.STRING(255), + allowNull: false, + }, + comment: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + parent_id: { + type: Sequelize.DataTypes.STRING(255), + allowNull: false, + defaultValue: '0', + }, + created_at: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + console.error('Failed to create VORTA social feed tables:', error); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tables = await queryInterface.showAllTables({ transaction }); + const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table)); + + if (tableNames.includes('vorta_social_comments')) { + await queryInterface.dropTable('vorta_social_comments', { transaction }); + } + + if (tableNames.includes('vorta_social_posts')) { + await queryInterface.dropTable('vorta_social_posts', { transaction }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + console.error('Failed to drop VORTA social feed tables:', error); + throw error; + } + }, +}; diff --git a/backend/src/db/models/vorta_commerce_states.js b/backend/src/db/models/vorta_commerce_states.js new file mode 100644 index 0000000..803807d --- /dev/null +++ b/backend/src/db/models/vorta_commerce_states.js @@ -0,0 +1,93 @@ +module.exports = function(sequelize, DataTypes) { + const vorta_commerce_states = sequelize.define( + 'vorta_commerce_states', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + settings: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + campaigns: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + ledger: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + product_meta: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + customer_meta: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + order_meta: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + shipment_meta: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + vorta_commerce_states.associate = (db) => { + db.vorta_commerce_states.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.vorta_commerce_states.belongsTo(db.stores, { + as: 'store', + foreignKey: { + name: 'storeId', + }, + constraints: false, + }); + + db.vorta_commerce_states.belongsTo(db.organizations, { + as: 'organizations', + foreignKey: { + name: 'organizationsId', + }, + constraints: false, + }); + + db.vorta_commerce_states.belongsTo(db.users, { + as: 'createdBy', + }); + + db.vorta_commerce_states.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return vorta_commerce_states; +}; diff --git a/backend/src/db/models/vorta_login_otps.js b/backend/src/db/models/vorta_login_otps.js new file mode 100644 index 0000000..8caa4ab --- /dev/null +++ b/backend/src/db/models/vorta_login_otps.js @@ -0,0 +1,75 @@ +module.exports = function(sequelize, DataTypes) { + const vorta_login_otps = sequelize.define( + 'vorta_login_otps', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + phone: { + type: DataTypes.STRING(40), + allowNull: false, + }, + otpHash: { + type: DataTypes.STRING(255), + allowNull: false, + }, + status: { + type: DataTypes.STRING(40), + allowNull: false, + defaultValue: 'pending', + }, + attempts: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false, + }, + verifiedAt: { + type: DataTypes.DATE, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + vorta_login_otps.associate = (db) => { + db.vorta_login_otps.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.vorta_login_otps.belongsTo(db.organizations, { + as: 'organizations', + foreignKey: { + name: 'organizationsId', + }, + constraints: false, + }); + + db.vorta_login_otps.belongsTo(db.users, { + as: 'createdBy', + }); + + db.vorta_login_otps.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return vorta_login_otps; +}; diff --git a/backend/src/db/models/vorta_social_comments.js b/backend/src/db/models/vorta_social_comments.js new file mode 100644 index 0000000..8574aab --- /dev/null +++ b/backend/src/db/models/vorta_social_comments.js @@ -0,0 +1,71 @@ +module.exports = function(sequelize, DataTypes) { + const vorta_social_comments = sequelize.define( + 'vorta_social_comments', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + post_id: { + type: DataTypes.UUID, + allowNull: false, + }, + user: { + type: DataTypes.STRING(255), + allowNull: false, + }, + comment: { + type: DataTypes.TEXT, + allowNull: false, + }, + parent_id: { + type: DataTypes.STRING(255), + allowNull: false, + defaultValue: '0', + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: false, + freezeTableName: true, + }, + ); + + vorta_social_comments.associate = (db) => { + db.vorta_social_comments.belongsTo(db.vorta_social_posts, { + as: 'post', + foreignKey: { + name: 'post_id', + }, + constraints: false, + }); + + db.vorta_social_comments.belongsTo(db.users, { + as: 'account', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.vorta_social_comments.belongsTo(db.organizations, { + as: 'organizations', + foreignKey: { + name: 'organizationsId', + }, + constraints: false, + }); + }; + + return vorta_social_comments; +}; diff --git a/backend/src/db/models/vorta_social_posts.js b/backend/src/db/models/vorta_social_posts.js new file mode 100644 index 0000000..60e9de0 --- /dev/null +++ b/backend/src/db/models/vorta_social_posts.js @@ -0,0 +1,62 @@ +module.exports = function(sequelize, DataTypes) { + const vorta_social_posts = sequelize.define( + 'vorta_social_posts', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + user: { + type: DataTypes.STRING(255), + allowNull: false, + }, + content: { + type: DataTypes.TEXT, + allowNull: false, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: false, + freezeTableName: true, + }, + ); + + vorta_social_posts.associate = (db) => { + db.vorta_social_posts.hasMany(db.vorta_social_comments, { + as: 'comments', + foreignKey: { + name: 'post_id', + }, + constraints: false, + }); + + db.vorta_social_posts.belongsTo(db.users, { + as: 'account', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.vorta_social_posts.belongsTo(db.organizations, { + as: 'organizations', + foreignKey: { + name: 'organizationsId', + }, + constraints: false, + }); + }; + + return vorta_social_posts; +}; diff --git a/backend/src/db/models/vorta_universe_states.js b/backend/src/db/models/vorta_universe_states.js new file mode 100644 index 0000000..646ea1d --- /dev/null +++ b/backend/src/db/models/vorta_universe_states.js @@ -0,0 +1,75 @@ +module.exports = function(sequelize, DataTypes) { + const vorta_universe_states = sequelize.define( + 'vorta_universe_states', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + profile: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + modules: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + products: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + ledger: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + settings: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + vorta_universe_states.associate = (db) => { + db.vorta_universe_states.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.vorta_universe_states.belongsTo(db.organizations, { + as: 'organizations', + foreignKey: { + name: 'organizationsId', + }, + constraints: false, + }); + + db.vorta_universe_states.belongsTo(db.users, { + as: 'createdBy', + }); + + db.vorta_universe_states.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return vorta_universe_states; +}; diff --git a/backend/src/index.js b/backend/src/index.js index 09526c7..1324fbd 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -6,7 +6,6 @@ const passport = require('passport'); const path = require('path'); const fs = require('fs'); const bodyParser = require('body-parser'); -const db = require('./db/models'); const config = require('./config'); const swaggerUI = require('swagger-ui-express'); const swaggerJsDoc = require('swagger-jsdoc'); @@ -20,6 +19,8 @@ const pexelsRoutes = require('./routes/pexels'); const organizationForAuthRoutes = require('./routes/organizationLogin'); const openaiRoutes = require('./routes/openai'); +const vortaCommerceRoutes = require('./routes/vortaCommerce'); +const vortaUniverseRoutes = require('./routes/vortaUniverse'); @@ -245,6 +246,9 @@ app.use('/api/api_clients', passport.authenticate('jwt', {session: false}), api_ app.use('/api/audit_events', passport.authenticate('jwt', {session: false}), audit_eventsRoutes); +app.use('/api/vorta-commerce', passport.authenticate('jwt', {session: false}), vortaCommerceRoutes); +app.use('/api/vorta-universe', passport.authenticate('jwt', {session: false}), vortaUniverseRoutes); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/vortaCommerce.js b/backend/src/routes/vortaCommerce.js new file mode 100644 index 0000000..96bf301 --- /dev/null +++ b/backend/src/routes/vortaCommerce.js @@ -0,0 +1,52 @@ +const express = require('express'); +const VortaCommerceService = require('../services/vortaCommerce'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +router.get('/state', wrapAsync(async (req, res) => { + const payload = await VortaCommerceService.getState(req.currentUser); + res.status(200).send(payload); +})); + +router.post('/orders/demo', wrapAsync(async (req, res) => { + const payload = await VortaCommerceService.createDemoOrder(req.currentUser); + res.status(200).send(payload); +})); + +router.post('/inventory/restock', wrapAsync(async (req, res) => { + const payload = await VortaCommerceService.restockLowStock(req.currentUser); + res.status(200).send(payload); +})); + +router.post('/payments/:paymentReference/capture', wrapAsync(async (req, res) => { + const payload = await VortaCommerceService.capturePayment(req.currentUser, req.params.paymentReference); + res.status(200).send(payload); +})); + +router.post('/orders/:orderNumber/advance', wrapAsync(async (req, res) => { + const payload = await VortaCommerceService.advanceOrder(req.currentUser, req.params.orderNumber); + res.status(200).send(payload); +})); + +router.post('/shipments/:trackingNumber/complete', wrapAsync(async (req, res) => { + const payload = await VortaCommerceService.completeShipment(req.currentUser, req.params.trackingNumber); + res.status(200).send(payload); +})); + +router.post('/campaigns/:campaignId/launch', wrapAsync(async (req, res) => { + const payload = await VortaCommerceService.launchCampaign(req.currentUser, req.params.campaignId); + res.status(200).send(payload); +})); + +router.post('/reports/close', wrapAsync(async (req, res) => { + const payload = await VortaCommerceService.closeDailyReport(req.currentUser); + res.status(200).send(payload); +})); + +router.patch('/settings', wrapAsync(async (req, res) => { + const payload = await VortaCommerceService.updateSettings(req.currentUser, req.body.data || {}); + res.status(200).send(payload); +})); + +module.exports = router; diff --git a/backend/src/routes/vortaUniverse.js b/backend/src/routes/vortaUniverse.js new file mode 100644 index 0000000..47fa3ac --- /dev/null +++ b/backend/src/routes/vortaUniverse.js @@ -0,0 +1,71 @@ +const express = require('express'); +const VortaUniverseService = require('../services/vortaUniverse'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +router.get('/state', wrapAsync(async (req, res) => { + const payload = await VortaUniverseService.getState(req.currentUser); + res.status(200).send(payload); +})); + +router.post('/state/reset', wrapAsync(async (req, res) => { + const payload = await VortaUniverseService.resetState(req.currentUser); + res.status(200).send(payload); +})); + +router.get('/social-feed', wrapAsync(async (req, res) => { + const payload = await VortaUniverseService.getSocialFeed(req.currentUser); + res.status(200).send(payload); +})); + +router.post('/social-feed/reset', wrapAsync(async (req, res) => { + const payload = await VortaUniverseService.resetSocialFeed(req.currentUser); + res.status(200).send(payload); +})); + +router.post('/posts', wrapAsync(async (req, res) => { + const payload = await VortaUniverseService.createSocialPost(req.currentUser, req.body.data || req.body || {}); + res.status(200).send(payload); +})); + +router.post('/posts/:postId/comments', wrapAsync(async (req, res) => { + const payload = await VortaUniverseService.createSocialComment( + req.currentUser, + req.params.postId, + req.body.data || req.body || {}, + ); + res.status(200).send(payload); +})); + +router.post('/auth/otp/request', wrapAsync(async (req, res) => { + const payload = await VortaUniverseService.requestOtp(req.currentUser, req.body.data || req.body || {}); + res.status(200).send(payload); +})); + +router.post('/auth/otp/verify', wrapAsync(async (req, res) => { + const payload = await VortaUniverseService.verifyOtp(req.currentUser, req.body.data || req.body || {}); + res.status(200).send(payload); +})); + +router.patch('/trust-score', wrapAsync(async (req, res) => { + const payload = await VortaUniverseService.updateTrustScore(req.currentUser, req.body.data || req.body || {}); + res.status(200).send(payload); +})); + +router.post('/modules/:moduleId/run', wrapAsync(async (req, res) => { + const payload = await VortaUniverseService.runModule(req.currentUser, req.params.moduleId); + res.status(200).send(payload); +})); + +router.post('/products', wrapAsync(async (req, res) => { + const payload = await VortaUniverseService.createProduct(req.currentUser, req.body.data || req.body || {}); + res.status(200).send(payload); +})); + +router.post('/products/demo', wrapAsync(async (req, res) => { + const payload = await VortaUniverseService.createDemoProduct(req.currentUser); + res.status(200).send(payload); +})); + +module.exports = router; diff --git a/backend/src/services/vortaCommerce.js b/backend/src/services/vortaCommerce.js new file mode 100644 index 0000000..717b6c5 --- /dev/null +++ b/backend/src/services/vortaCommerce.js @@ -0,0 +1,1466 @@ +const db = require('../db/models'); + +const { Op } = db.Sequelize; + +const DEFAULT_PRODUCTS = [ + { + sku: 'SKU-EL-001', + name: 'Vorta Smart Speaker X1', + category: 'Elektronik', + price: 1299000, + stock: 42, + reorderPoint: 12, + reorderPack: 30, + description: 'Speaker pintar untuk rumah, toko, dan kantor kecil.', + sold: 318, + margin: 28, + trend: 12, + }, + { + sku: 'SKU-FA-044', + name: 'Nusa Everyday Tote', + category: 'Fashion', + price: 249000, + stock: 9, + reorderPoint: 16, + reorderPack: 60, + description: 'Tas kanvas premium untuk kreator dan pekerja mobile.', + sold: 742, + margin: 35, + trend: 18, + }, + { + sku: 'SKU-BE-117', + name: 'Lumin Glow Serum', + category: 'Beauty', + price: 189000, + stock: 24, + reorderPoint: 20, + reorderPack: 80, + description: 'Serum wajah lokal dengan bundling campaign affiliate.', + sold: 1094, + margin: 42, + trend: 25, + }, + { + sku: 'SKU-HO-073', + name: 'Bamboo Desk Organizer', + category: 'Home Living', + price: 159000, + stock: 7, + reorderPoint: 14, + reorderPack: 45, + description: 'Organizer meja kerja dari bambu daur ulang.', + sold: 286, + margin: 31, + trend: -4, + }, +]; + +const DEFAULT_CUSTOMERS = [ + { + code: 'CUST-001', + firstName: 'Alya', + lastName: 'Paramitha', + phone: '+62 812 1000 2040', + email: 'alya@vorta.demo', + address: 'Jakarta Selatan', + city: 'Jakarta Selatan', + province: 'DKI Jakarta', + segment: 'VIP', + orders: 18, + lifetimeValue: 18450000, + }, + { + code: 'CUST-002', + firstName: 'Raka', + lastName: 'Wijaya', + phone: '+62 813 4400 8080', + email: 'raka@vorta.demo', + address: 'Bandung', + city: 'Bandung', + province: 'Jawa Barat', + segment: 'Reguler', + orders: 7, + lifetimeValue: 6390000, + }, + { + code: 'CUST-003', + firstName: 'Sari Nusantara', + lastName: 'Mart', + phone: '+62 21 5550 9911', + email: 'procurement@sarinusa.demo', + address: 'Surabaya', + city: 'Surabaya', + province: 'Jawa Timur', + segment: 'VIP', + orders: 31, + lifetimeValue: 52800000, + }, + { + code: 'CUST-004', + firstName: 'Dimas', + lastName: 'Kurnia', + phone: '+62 857 9000 1133', + email: 'dimas@vorta.demo', + address: 'Yogyakarta', + city: 'Yogyakarta', + province: 'DI Yogyakarta', + segment: 'Baru', + orders: 1, + lifetimeValue: 1299000, + }, +]; + +const DEFAULT_ORDERS = [ + { + number: 'ORD-24061', + customerCode: 'CUST-001', + total: 1548000, + shipping: 25000, + status: 'processing', + channel: 'Web Store', + createdAtLabel: 'Hari ini, 09:24', + placedMinutesAgo: 160, + paid: true, + items: [ + { sku: 'SKU-EL-001', quantity: 1 }, + { sku: 'SKU-FA-044', quantity: 1 }, + ], + }, + { + number: 'ORD-24062', + customerCode: 'CUST-002', + total: 567000, + shipping: 0, + status: 'pending_payment', + channel: 'Marketplace', + createdAtLabel: 'Hari ini, 10:02', + placedMinutesAgo: 122, + paid: false, + items: [{ sku: 'SKU-BE-117', quantity: 3 }], + }, + { + number: 'ORD-24063', + customerCode: 'CUST-003', + total: 7950000, + shipping: 0, + status: 'shipped', + channel: 'B2B', + createdAtLabel: 'Kemarin, 16:48', + placedMinutesAgo: 1296, + paid: true, + items: [{ sku: 'SKU-HO-073', quantity: 50 }], + }, + { + number: 'ORD-24064', + customerCode: 'CUST-004', + total: 1299000, + shipping: 0, + status: 'pending_payment', + channel: 'Social Commerce', + createdAtLabel: 'Hari ini, 11:18', + placedMinutesAgo: 46, + paid: false, + items: [{ sku: 'SKU-EL-001', quantity: 1 }], + }, +]; + +const DEFAULT_PAYMENTS = [ + { reference: 'PAY-8801', orderNumber: 'ORD-24061', method: 'qris', amount: 1548000, status: 'captured' }, + { reference: 'PAY-8802', orderNumber: 'ORD-24062', method: 'bank_transfer', amount: 567000, status: 'initiated' }, + { reference: 'PAY-8803', orderNumber: 'ORD-24063', method: 'card', amount: 7950000, status: 'captured' }, + { reference: 'PAY-8804', orderNumber: 'ORD-24064', method: 'qris', amount: 1299000, status: 'initiated' }, +]; + +const DEFAULT_SHIPMENTS = [ + { + tracking: 'SHP-7101', + orderNumber: 'ORD-24061', + carrier: 'other', + courier: 'Vorta Express', + destination: 'Jakarta Selatan', + eta: 'Hari ini 15:00', + status: 'ready_to_ship', + risk: 'Rendah', + }, + { + tracking: 'SHP-7102', + orderNumber: 'ORD-24063', + carrier: 'dhl', + courier: 'DHL B2B Freight', + destination: 'Surabaya', + eta: 'Besok 12:00', + status: 'in_transit', + risk: 'Sedang', + }, + { + tracking: 'SHP-7103', + orderNumber: 'ORD-24064', + carrier: 'gosend', + courier: 'GoSend Instant', + destination: 'Yogyakarta', + eta: 'Menunggu payment', + status: 'ready_to_ship', + risk: 'Rendah', + }, +]; + +const DEFAULT_CAMPAIGNS = [ + { + id: 'MKT-501', + name: 'Payday Electronics Push', + channel: 'Push', + budget: 4500000, + status: 'Aktif', + conversion: 6.8, + revenue: 38250000, + }, + { + id: 'MKT-502', + name: 'Beauty Affiliate Sprint', + channel: 'Affiliate', + budget: 2800000, + status: 'Draft', + conversion: 3.1, + revenue: 11450000, + }, + { + id: 'MKT-503', + name: 'VIP Repeat Buyer Email', + channel: 'Email', + budget: 650000, + status: 'Selesai', + conversion: 9.4, + revenue: 19750000, + }, +]; + +const DEFAULT_LEDGER = [ + { + id: 'LOG-1', + title: 'Payment capture berhasil', + detail: 'ORD-24061 lunas via QRIS dan siap masuk antrean pick up.', + time: '2 menit lalu', + tone: 'success', + }, + { + id: 'LOG-2', + title: 'Low stock alert', + detail: 'Nusa Everyday Tote dan Bamboo Desk Organizer di bawah reorder point.', + time: '9 menit lalu', + tone: 'warning', + }, + { + id: 'LOG-3', + title: 'Campaign aktif', + detail: 'Payday Electronics Push menghasilkan ROAS awal 8.5x.', + time: '18 menit lalu', + tone: 'info', + }, +]; + +const DEFAULT_SETTINGS = { + storeName: 'VORTA-COMMERCE HQ', + currency: 'IDR', + fulfillmentMode: 'Otomatis', + lowStockAlert: true, + paymentAutoCapture: true, +}; + +const paymentMethodLabels = { + wallet: 'Wallet', + bank_transfer: 'VA Bank', + card: 'Kartu Kredit', + qris: 'QRIS', +}; + +const carrierLabels = { + jne: 'JNE', + jnt: 'J&T', + sicepat: 'SiCepat', + pos: 'POS Indonesia', + anteraja: 'Anteraja', + gosend: 'GoSend Instant', + grabexpress: 'GrabExpress', + dhl: 'DHL B2B Freight', + fedex: 'FedEx', + other: 'Vorta Express', +}; + +const toNumber = (value) => Number(value || 0); + +const slugify = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); + +const cloneJson = (value) => JSON.parse(JSON.stringify(value)); + +const makeImportHash = (name, currentUser) => `vorta-commerce-${name}-${currentUser.id}`; + +const getOrganizationId = (currentUser) => ( + currentUser.organizations?.id + || currentUser.organizationsId + || currentUser.organizationId + || null +); + +const throwBadRequest = (message) => { + const error = new Error(message); + error.code = 400; + throw error; +}; + +const mapOrderStatusToUi = (status) => { + if (['pending_payment'].includes(status)) return 'Baru'; + if (['paid', 'processing'].includes(status)) return 'Diproses'; + if (['shipped'].includes(status)) return 'Dikirim'; + return 'Selesai'; +}; + +const mapPaymentStatusToUi = (status) => { + if (status === 'captured') return 'Berhasil'; + if (status === 'failed') return 'Gagal'; + if (status === 'refunded') return 'Refund'; + return 'Menunggu'; +}; + +const mapShipmentStatusToUi = (status) => { + if (['delivered'].includes(status)) return 'Terkirim'; + if (['in_transit', 'out_for_delivery'].includes(status)) return 'Dikirim'; + if (['returned', 'cancelled'].includes(status)) return 'Tahan QC'; + return 'Menunggu Pick Up'; +}; + +const formatDateLabel = (dateValue, fallback) => { + if (fallback) return fallback; + if (!dateValue) return 'Baru saja'; + + const date = new Date(dateValue); + const now = new Date(); + const sameDay = date.toDateString() === now.toDateString(); + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + + const time = new Intl.DateTimeFormat('id-ID', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); + + if (sameDay) return `Hari ini, ${time}`; + if (date.toDateString() === yesterday.toDateString()) return `Kemarin, ${time}`; + + return new Intl.DateTimeFormat('id-ID', { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); +}; + +class VortaCommerceService { + static get defaultProductMeta() { + return DEFAULT_PRODUCTS.reduce((meta, product) => { + meta[product.sku] = { + reorderPoint: product.reorderPoint, + reorderPack: product.reorderPack, + sold: product.sold, + margin: product.margin, + trend: product.trend, + }; + return meta; + }, {}); + } + + static get defaultCustomerMeta() { + return DEFAULT_CUSTOMERS.reduce((meta, customer) => { + meta[customer.code] = { + email: customer.email, + address: customer.address, + segment: customer.segment, + orders: customer.orders, + lifetimeValue: customer.lifetimeValue, + }; + return meta; + }, {}); + } + + static pushLedger(ledger, title, detail, tone = 'info') { + return [ + { + id: `LOG-${Date.now()}`, + title, + detail, + time: 'Baru saja', + tone, + }, + ...(Array.isArray(ledger) ? ledger : []), + ].slice(0, 8); + } + + static async getState(currentUser) { + await db.sequelize.transaction(async (transaction) => { + await this.ensureDemoData(currentUser, transaction); + }); + + return this.buildState(currentUser); + } + + static async ensureDemoData(currentUser, transaction) { + const organizationsId = getOrganizationId(currentUser); + const stateImportHash = makeImportHash('state', currentUser); + + const [state] = await db.vorta_commerce_states.findOrCreate({ + where: { importHash: stateImportHash }, + defaults: { + userId: currentUser.id, + organizationsId, + settings: cloneJson(DEFAULT_SETTINGS), + campaigns: cloneJson(DEFAULT_CAMPAIGNS), + ledger: cloneJson(DEFAULT_LEDGER), + product_meta: this.defaultProductMeta, + customer_meta: this.defaultCustomerMeta, + order_meta: {}, + shipment_meta: {}, + importHash: stateImportHash, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + transaction, + }); + + const mergedProductMeta = { + ...this.defaultProductMeta, + ...(state.product_meta || {}), + }; + const mergedCustomerMeta = { + ...this.defaultCustomerMeta, + ...(state.customer_meta || {}), + }; + + if (!state.storeId || !state.product_meta || !state.customer_meta) { + await state.update( + { + product_meta: mergedProductMeta, + customer_meta: mergedCustomerMeta, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + const store = await this.ensureStore(currentUser, state, organizationsId, transaction); + const categories = await this.ensureCategories(currentUser, organizationsId, transaction); + const products = await this.ensureProducts(currentUser, store, categories, transaction); + const customers = await this.ensureCustomers(currentUser, organizationsId, transaction); + const addresses = await this.ensureAddresses(currentUser, customers, organizationsId, transaction); + const orders = await this.ensureOrders(currentUser, store, customers, addresses, products, organizationsId, transaction); + + await this.ensurePayments(currentUser, orders, customers, organizationsId, transaction); + await this.ensureShipments(currentUser, orders, organizationsId, transaction); + } + + static async ensureStore(currentUser, state, organizationsId, transaction) { + const importHash = makeImportHash('store', currentUser); + const settings = { + ...DEFAULT_SETTINGS, + ...(state.settings || {}), + }; + + const [store] = await db.stores.findOrCreate({ + where: { importHash }, + defaults: { + store_name: settings.storeName, + store_slug: `vorta-commerce-${currentUser.id.slice(0, 8)}`, + store_type: 'brand', + status: 'active', + description: 'Persistent VORTA-COMMERCE demo store backed by the generated commerce entities.', + support_phone: '+62 21 8888 2026', + support_email: currentUser.email || 'commerce@vorta.demo', + ownerId: currentUser.id, + organizationsId, + importHash, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + transaction, + }); + + if (!state.storeId || state.storeId !== store.id) { + await state.update( + { + storeId: store.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + return store; + } + + static async ensureCategories(currentUser, organizationsId, transaction) { + const categories = {}; + + for (const product of DEFAULT_PRODUCTS) { + const importHash = makeImportHash(`category-${slugify(product.category)}`, currentUser); + const [category] = await db.product_categories.findOrCreate({ + where: { importHash }, + defaults: { + category_name: product.category, + category_slug: slugify(product.category), + visibility: 'public', + description: `Kategori ${product.category} untuk VORTA-COMMERCE.`, + organizationsId, + importHash, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + transaction, + }); + + categories[product.category] = category; + } + + return categories; + } + + static async ensureProducts(currentUser, store, categories, transaction) { + const products = {}; + + for (const product of DEFAULT_PRODUCTS) { + const importHash = makeImportHash(`product-${product.sku}`, currentUser); + const category = categories[product.category]; + const costPrice = Math.round(product.price * (1 - product.margin / 100)); + const [record] = await db.products.findOrCreate({ + where: { importHash }, + defaults: { + product_name: product.name, + sku: product.sku, + description: product.description, + product_type: 'physical', + price: product.price, + cost_price: costPrice, + stock_quantity: product.stock, + status: product.stock > 0 ? 'active' : 'out_of_stock', + local_priority_eligible: true, + storeId: store.id, + categoryId: category.id, + organizationsId: store.organizationsId, + importHash, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + transaction, + }); + + products[product.sku] = record; + } + + return products; + } + + static async ensureCustomers(currentUser, organizationsId, transaction) { + const customers = {}; + + for (const customer of DEFAULT_CUSTOMERS) { + const importHash = makeImportHash(`customer-${customer.code}`, currentUser); + const [record] = await db.users.findOrCreate({ + where: { importHash }, + defaults: { + firstName: customer.firstName, + lastName: customer.lastName, + phoneNumber: customer.phone, + email: customer.email, + disabled: false, + emailVerified: true, + provider: 'local', + organizationsId, + importHash, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + transaction, + }); + + customers[customer.code] = record; + } + + return customers; + } + + static async ensureAddresses(currentUser, customers, organizationsId, transaction) { + const addresses = {}; + + for (const customer of DEFAULT_CUSTOMERS) { + const importHash = makeImportHash(`address-${customer.code}`, currentUser); + const [address] = await db.addresses.findOrCreate({ + where: { importHash }, + defaults: { + label: 'Alamat Utama', + recipient_name: `${customer.firstName} ${customer.lastName}`, + phone_number: customer.phone, + country: 'Indonesia', + province: customer.province, + city: customer.city, + district: '-', + postal_code: '00000', + street_address: customer.address, + is_default: true, + userId: customers[customer.code].id, + organizationsId, + importHash, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + transaction, + }); + + addresses[customer.code] = address; + } + + return addresses; + } + + static async ensureOrders(currentUser, store, customers, addresses, products, organizationsId, transaction) { + const orders = {}; + + for (const order of DEFAULT_ORDERS) { + const importHash = makeImportHash(`order-${order.number}`, currentUser); + const placedAt = new Date(Date.now() - order.placedMinutesAgo * 60 * 1000); + const [record] = await db.orders.findOrCreate({ + where: { importHash }, + defaults: { + order_number: order.number, + order_status: order.status, + subtotal_amount: Math.max(order.total - order.shipping, 0), + shipping_amount: order.shipping, + total_amount: order.total, + placed_at: placedAt, + paid_at: order.paid ? placedAt : null, + buyer_note: `Channel: ${order.channel}`, + buyerId: customers[order.customerCode].id, + storeId: store.id, + shipping_addressId: addresses[order.customerCode].id, + organizationsId, + importHash, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + transaction, + }); + + for (const item of order.items) { + const product = products[item.sku]; + const itemImportHash = makeImportHash(`order-item-${order.number}-${item.sku}`, currentUser); + await db.order_items.findOrCreate({ + where: { importHash: itemImportHash }, + defaults: { + quantity: item.quantity, + unit_price: toNumber(product.price), + line_total: toNumber(product.price) * item.quantity, + variant_label: item.quantity > 1 ? `${product.product_name} x${item.quantity}` : product.product_name, + orderId: record.id, + productId: product.id, + organizationsId, + importHash: itemImportHash, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + transaction, + }); + } + + orders[order.number] = record; + } + + return orders; + } + + static async ensurePayments(currentUser, orders, customers, organizationsId, transaction) { + for (const payment of DEFAULT_PAYMENTS) { + const order = orders[payment.orderNumber]; + const orderConfig = DEFAULT_ORDERS.find((item) => item.number === payment.orderNumber); + const customer = customers[orderConfig.customerCode]; + const importHash = makeImportHash(`payment-${payment.reference}`, currentUser); + + await db.payments.findOrCreate({ + where: { importHash }, + defaults: { + payment_reference: payment.reference, + method: payment.method, + status: payment.status, + amount: payment.amount, + initiated_at: order.placed_at || new Date(), + captured_at: payment.status === 'captured' ? order.paid_at || new Date() : null, + orderId: order.id, + payerId: customer.id, + organizationsId, + importHash, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + transaction, + }); + } + } + + static async ensureShipments(currentUser, orders, organizationsId, transaction) { + for (const shipment of DEFAULT_SHIPMENTS) { + const order = orders[shipment.orderNumber]; + const importHash = makeImportHash(`shipment-${shipment.tracking}`, currentUser); + + await db.shipments.findOrCreate({ + where: { importHash }, + defaults: { + carrier: shipment.carrier, + tracking_number: shipment.tracking, + shipment_status: shipment.status, + shipped_at: ['in_transit', 'out_for_delivery', 'delivered'].includes(shipment.status) ? new Date() : null, + delivered_at: shipment.status === 'delivered' ? new Date() : null, + shipping_fee: 25000, + orderId: order.id, + organizationsId, + importHash, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + transaction, + }); + } + } + + static async buildState(currentUser) { + const state = await db.vorta_commerce_states.findOne({ + where: { importHash: makeImportHash('state', currentUser) }, + }); + + if (!state) { + throwBadRequest('VORTA-COMMERCE state is not initialized.'); + } + + const store = await db.stores.findByPk(state.storeId); + + if (!store) { + throwBadRequest('VORTA-COMMERCE store is not initialized.'); + } + + const productMeta = { + ...this.defaultProductMeta, + ...(state.product_meta || {}), + }; + const customerMeta = { + ...this.defaultCustomerMeta, + ...(state.customer_meta || {}), + }; + const orderMeta = state.order_meta || {}; + const shipmentMeta = state.shipment_meta || {}; + + const products = await db.products.findAll({ + where: { storeId: store.id }, + include: [{ model: db.product_categories, as: 'category' }], + order: [['sku', 'ASC']], + }); + + const orders = await db.orders.findAll({ + where: { storeId: store.id }, + include: [ + { model: db.users, as: 'buyer' }, + { model: db.order_items, as: 'order_items_order', include: [{ model: db.products, as: 'product' }] }, + ], + order: [['createdAt', 'DESC']], + }); + + const orderIds = orders.map((order) => order.id); + const payments = orderIds.length + ? await db.payments.findAll({ where: { orderId: { [Op.in]: orderIds } }, order: [['createdAt', 'DESC']] }) + : []; + const shipments = orderIds.length + ? await db.shipments.findAll({ where: { orderId: { [Op.in]: orderIds } }, order: [['createdAt', 'DESC']] }) + : []; + + const customerRecords = await db.users.findAll({ + where: { + importHash: { + [Op.in]: DEFAULT_CUSTOMERS.map((customer) => makeImportHash(`customer-${customer.code}`, currentUser)), + }, + }, + order: [['createdAt', 'ASC']], + }); + + const customers = DEFAULT_CUSTOMERS.map((defaultCustomer) => { + const record = customerRecords.find( + (customer) => customer.importHash === makeImportHash(`customer-${defaultCustomer.code}`, currentUser), + ); + const meta = customerMeta[defaultCustomer.code] || {}; + + return { + id: defaultCustomer.code, + name: record ? `${record.firstName || ''} ${record.lastName || ''}`.trim() : `${defaultCustomer.firstName} ${defaultCustomer.lastName}`, + phone: record?.phoneNumber || defaultCustomer.phone, + email: record?.email || defaultCustomer.email, + address: meta.address || defaultCustomer.address, + segment: meta.segment || defaultCustomer.segment, + orders: toNumber(meta.orders || defaultCustomer.orders), + lifetimeValue: toNumber(meta.lifetimeValue || defaultCustomer.lifetimeValue), + }; + }); + + const ordersById = orders.reduce((map, order) => { + map[order.id] = order; + return map; + }, {}); + const paymentsByOrderId = payments.reduce((map, payment) => { + if (!map[payment.orderId]) map[payment.orderId] = []; + map[payment.orderId].push(payment); + return map; + }, {}); + const shipmentsByOrderId = shipments.reduce((map, shipment) => { + if (!map[shipment.orderId]) map[shipment.orderId] = []; + map[shipment.orderId].push(shipment); + return map; + }, {}); + + const uiProducts = products.map((product) => { + const meta = productMeta[product.sku] || {}; + const price = toNumber(product.price); + const costPrice = toNumber(product.cost_price); + const margin = price ? Math.round(((price - costPrice) / price) * 100) : toNumber(meta.margin); + + return { + id: product.sku, + name: product.product_name, + category: product.category?.category_name || 'Umum', + price, + stock: toNumber(product.stock_quantity), + reorderPoint: toNumber(meta.reorderPoint), + reorderPack: toNumber(meta.reorderPack), + description: product.description || '', + sold: toNumber(meta.sold), + margin: Number.isFinite(margin) ? margin : toNumber(meta.margin), + trend: toNumber(meta.trend), + }; + }); + + const uiOrders = orders.map((order) => { + const payment = paymentsByOrderId[order.id]?.[0]; + const shipment = shipmentsByOrderId[order.id]?.[0]; + const meta = orderMeta[order.order_number] || {}; + const customerCode = DEFAULT_CUSTOMERS.find((customer) => customer.email === order.buyer?.email)?.code || meta.customerId || 'CUST-001'; + const items = (order.order_items_order || []).map((item) => ( + item.variant_label || `${item.product?.product_name || 'Produk'}${item.quantity > 1 ? ` x${item.quantity}` : ''}` + )); + + return { + id: order.order_number, + customerId: customerCode, + total: toNumber(order.total_amount), + status: mapOrderStatusToUi(order.order_status), + paymentStatus: mapPaymentStatusToUi(payment?.status), + shipmentStatus: mapShipmentStatusToUi(shipment?.shipment_status), + channel: meta.channel || order.buyer_note?.replace('Channel: ', '') || 'Web Store', + createdAt: formatDateLabel(order.placed_at, meta.createdAtLabel), + items, + }; + }); + + const uiPayments = payments.map((payment) => ({ + id: payment.payment_reference, + orderId: ordersById[payment.orderId]?.order_number || payment.orderId, + method: paymentMethodLabels[payment.method] || 'QRIS', + amount: toNumber(payment.amount), + status: mapPaymentStatusToUi(payment.status), + })); + + const uiShipments = shipments.map((shipment) => { + const orderNumber = ordersById[shipment.orderId]?.order_number || shipment.orderId; + const meta = shipmentMeta[shipment.tracking_number] || {}; + + return { + id: shipment.tracking_number, + orderId: orderNumber, + destination: meta.destination || this.destinationForOrder(orderNumber), + courier: meta.courier || carrierLabels[shipment.carrier] || 'Vorta Express', + eta: shipment.shipment_status === 'delivered' ? 'Selesai' : meta.eta || 'Hari ini', + status: mapShipmentStatusToUi(shipment.shipment_status), + risk: meta.risk || 'Rendah', + }; + }); + + return { + products: uiProducts, + customers, + orders: uiOrders, + payments: uiPayments, + shipments: uiShipments, + campaigns: Array.isArray(state.campaigns) ? state.campaigns : cloneJson(DEFAULT_CAMPAIGNS), + ledger: Array.isArray(state.ledger) ? state.ledger : cloneJson(DEFAULT_LEDGER), + settings: { + ...DEFAULT_SETTINGS, + ...(state.settings || {}), + }, + persistedAt: new Date().toISOString(), + }; + } + + static destinationForOrder(orderNumber) { + const orderConfig = DEFAULT_ORDERS.find((order) => order.number === orderNumber); + const customer = DEFAULT_CUSTOMERS.find((item) => item.code === orderConfig?.customerCode); + return customer?.address || 'Indonesia'; + } + + static async createDemoOrder(currentUser) { + await db.sequelize.transaction(async (transaction) => { + await this.ensureDemoData(currentUser, transaction); + + const state = await db.vorta_commerce_states.findOne({ + where: { importHash: makeImportHash('state', currentUser) }, + transaction, + }); + const store = await db.stores.findByPk(state.storeId, { transaction }); + const product = await db.products.findOne({ + where: { storeId: store.id, sku: 'SKU-BE-117' }, + transaction, + }); + + if (!product) throwBadRequest('Produk demo tidak ditemukan.'); + + const quantity = 3; + if (toNumber(product.stock_quantity) < quantity) { + throwBadRequest(`${product.product_name} tidak punya stok cukup.`); + } + + const customerConfig = DEFAULT_CUSTOMERS.find((customer) => customer.code === 'CUST-002'); + const customer = await db.users.findOne({ + where: { importHash: makeImportHash('customer-CUST-002', currentUser) }, + transaction, + }); + const address = await db.addresses.findOne({ + where: { importHash: makeImportHash('address-CUST-002', currentUser) }, + transaction, + }); + const orderNumber = await this.nextNumber( + 'ORD-', + db.orders, + 'order_number', + { storeId: store.id }, + transaction, + 24060, + ); + const paymentReference = await this.nextNumber( + 'PAY-', + db.payments, + 'payment_reference', + { importHash: { [Op.like]: `vorta-commerce-payment-PAY-%-${currentUser.id}` } }, + transaction, + 8800, + ); + const trackingNumber = await this.nextNumber( + 'SHP-', + db.shipments, + 'tracking_number', + { importHash: { [Op.like]: `vorta-commerce-shipment-SHP-%-${currentUser.id}` } }, + transaction, + 7100, + ); + const shipping = 25000; + const total = toNumber(product.price) * quantity + shipping; + const organizationsId = getOrganizationId(currentUser); + const now = new Date(); + + await product.update( + { + stock_quantity: toNumber(product.stock_quantity) - quantity, + updatedById: currentUser.id, + }, + { transaction }, + ); + + const order = await db.orders.create( + { + order_number: orderNumber, + order_status: 'pending_payment', + subtotal_amount: total - shipping, + shipping_amount: shipping, + total_amount: total, + placed_at: now, + buyer_note: 'Channel: Social Commerce', + buyerId: customer.id, + storeId: store.id, + shipping_addressId: address.id, + organizationsId, + importHash: makeImportHash(`order-${orderNumber}`, currentUser), + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await db.order_items.create( + { + quantity, + unit_price: product.price, + line_total: total - shipping, + variant_label: `${product.product_name} x${quantity}`, + orderId: order.id, + productId: product.id, + organizationsId, + importHash: makeImportHash(`order-item-${orderNumber}-${product.sku}`, currentUser), + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await db.payments.create( + { + payment_reference: paymentReference, + method: 'qris', + status: 'initiated', + amount: total, + initiated_at: now, + orderId: order.id, + payerId: customer.id, + organizationsId, + importHash: makeImportHash(`payment-${paymentReference}`, currentUser), + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await db.shipments.create( + { + carrier: 'other', + tracking_number: trackingNumber, + shipment_status: 'ready_to_ship', + shipping_fee: shipping, + orderId: order.id, + organizationsId, + importHash: makeImportHash(`shipment-${trackingNumber}`, currentUser), + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + const productMeta = { + ...this.defaultProductMeta, + ...(state.product_meta || {}), + }; + const customerMeta = { + ...this.defaultCustomerMeta, + ...(state.customer_meta || {}), + }; + const shipmentMeta = state.shipment_meta || {}; + const orderMeta = state.order_meta || {}; + + productMeta[product.sku] = { + ...(productMeta[product.sku] || {}), + sold: toNumber(productMeta[product.sku]?.sold) + quantity, + }; + customerMeta[customerConfig.code] = { + ...(customerMeta[customerConfig.code] || {}), + email: customerConfig.email, + address: customerConfig.address, + segment: customerConfig.segment, + orders: toNumber(customerMeta[customerConfig.code]?.orders || customerConfig.orders) + 1, + lifetimeValue: toNumber(customerMeta[customerConfig.code]?.lifetimeValue || customerConfig.lifetimeValue) + total, + }; + orderMeta[orderNumber] = { + channel: 'Social Commerce', + createdAtLabel: 'Baru saja', + customerId: customerConfig.code, + }; + shipmentMeta[trackingNumber] = { + destination: customerConfig.address, + courier: 'Vorta Express', + eta: '6 jam', + risk: 'Rendah', + }; + + await state.update( + { + product_meta: productMeta, + customer_meta: customerMeta, + order_meta: orderMeta, + shipment_meta: shipmentMeta, + ledger: this.pushLedger(state.ledger, 'Flash order dibuat', `${orderNumber} dibuat dari Social Commerce.`, 'success'), + updatedById: currentUser.id, + }, + { transaction }, + ); + }); + + return this.getState(currentUser); + } + + static async nextNumber(prefix, model, column, where, transaction, fallbackNumber) { + const records = await model.findAll({ + where, + attributes: [column], + transaction, + }); + const maxNumber = records.reduce((max, record) => { + const raw = record[column] || ''; + const numeric = Number(String(raw).replace(/\D/g, '')); + return Number.isFinite(numeric) ? Math.max(max, numeric) : max; + }, fallbackNumber); + + return `${prefix}${maxNumber + 1}`; + } + + static async restockLowStock(currentUser) { + await db.sequelize.transaction(async (transaction) => { + await this.ensureDemoData(currentUser, transaction); + + const state = await db.vorta_commerce_states.findOne({ + where: { importHash: makeImportHash('state', currentUser) }, + transaction, + }); + const store = await db.stores.findByPk(state.storeId, { transaction }); + const products = await db.products.findAll({ where: { storeId: store.id }, transaction }); + const productMeta = { + ...this.defaultProductMeta, + ...(state.product_meta || {}), + }; + const lowStockProducts = products.filter((product) => ( + toNumber(product.stock_quantity) <= toNumber(productMeta[product.sku]?.reorderPoint) + )); + + if (!lowStockProducts.length) { + await state.update( + { + ledger: this.pushLedger(state.ledger, 'Inventori aman', 'Tidak ada produk di bawah reorder point saat ini.', 'success'), + updatedById: currentUser.id, + }, + { transaction }, + ); + return; + } + + for (const product of lowStockProducts) { + await product.update( + { + stock_quantity: toNumber(product.stock_quantity) + toNumber(productMeta[product.sku]?.reorderPack), + status: 'active', + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + await state.update( + { + ledger: this.pushLedger( + state.ledger, + 'Auto-restock dibuat', + `${lowStockProducts.length} SKU masuk purchase order otomatis dari inventori.`, + 'success', + ), + updatedById: currentUser.id, + }, + { transaction }, + ); + }); + + return this.getState(currentUser); + } + + static async capturePayment(currentUser, paymentReference) { + await db.sequelize.transaction(async (transaction) => { + await this.ensureDemoData(currentUser, transaction); + + const payment = await db.payments.findOne({ + where: { payment_reference: paymentReference }, + include: [{ model: db.orders, as: 'order' }], + transaction, + }); + + if (!payment || payment.order?.storeId !== (await this.currentStoreId(currentUser, transaction))) { + throwBadRequest('Payment tidak ditemukan.'); + } + + if (payment.status === 'captured') return; + + await payment.update( + { + status: 'captured', + captured_at: new Date(), + updatedById: currentUser.id, + }, + { transaction }, + ); + await payment.order.update( + { + order_status: payment.order.order_status === 'pending_payment' ? 'processing' : payment.order.order_status, + paid_at: payment.order.paid_at || new Date(), + updatedById: currentUser.id, + }, + { transaction }, + ); + + const state = await db.vorta_commerce_states.findOne({ + where: { importHash: makeImportHash('state', currentUser) }, + transaction, + }); + await state.update( + { + ledger: this.pushLedger( + state.ledger, + 'Pembayaran sukses', + `${payment.order.order_number} ditandai lunas via ${paymentMethodLabels[payment.method] || payment.method}.`, + 'success', + ), + updatedById: currentUser.id, + }, + { transaction }, + ); + }); + + return this.getState(currentUser); + } + + static async advanceOrder(currentUser, orderNumber) { + await db.sequelize.transaction(async (transaction) => { + await this.ensureDemoData(currentUser, transaction); + + const storeId = await this.currentStoreId(currentUser, transaction); + const order = await db.orders.findOne({ + where: { order_number: orderNumber, storeId }, + transaction, + }); + + if (!order) throwBadRequest('Order tidak ditemukan.'); + + const nextStatus = { + pending_payment: 'processing', + paid: 'processing', + processing: 'shipped', + shipped: 'completed', + delivered: 'completed', + completed: 'completed', + cancelled: 'cancelled', + refunded: 'refunded', + }[order.order_status] || 'processing'; + + await order.update( + { + order_status: nextStatus, + updatedById: currentUser.id, + }, + { transaction }, + ); + + const shipment = await db.shipments.findOne({ where: { orderId: order.id }, transaction }); + if (shipment) { + await shipment.update( + { + shipment_status: nextStatus === 'shipped' ? 'in_transit' : nextStatus === 'completed' ? 'delivered' : shipment.shipment_status, + shipped_at: nextStatus === 'shipped' ? new Date() : shipment.shipped_at, + delivered_at: nextStatus === 'completed' ? new Date() : shipment.delivered_at, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + const state = await db.vorta_commerce_states.findOne({ + where: { importHash: makeImportHash('state', currentUser) }, + transaction, + }); + await state.update( + { + ledger: this.pushLedger( + state.ledger, + 'Status pesanan diperbarui', + `${orderNumber} berpindah ke status ${mapOrderStatusToUi(nextStatus)}.`, + 'info', + ), + updatedById: currentUser.id, + }, + { transaction }, + ); + }); + + return this.getState(currentUser); + } + + static async completeShipment(currentUser, trackingNumber) { + await db.sequelize.transaction(async (transaction) => { + await this.ensureDemoData(currentUser, transaction); + + const storeId = await this.currentStoreId(currentUser, transaction); + const shipment = await db.shipments.findOne({ + where: { tracking_number: trackingNumber }, + include: [{ model: db.orders, as: 'order' }], + transaction, + }); + + if (!shipment || shipment.order?.storeId !== storeId) throwBadRequest('Shipment tidak ditemukan.'); + if (shipment.shipment_status === 'delivered') return; + + await shipment.update( + { + shipment_status: 'delivered', + delivered_at: new Date(), + updatedById: currentUser.id, + }, + { transaction }, + ); + await shipment.order.update( + { + order_status: 'completed', + updatedById: currentUser.id, + }, + { transaction }, + ); + + const state = await db.vorta_commerce_states.findOne({ + where: { importHash: makeImportHash('state', currentUser) }, + transaction, + }); + const meta = state.shipment_meta?.[trackingNumber] || {}; + await state.update( + { + ledger: this.pushLedger( + state.ledger, + 'Paket terkirim', + `${shipment.order.order_number} selesai oleh ${meta.courier || carrierLabels[shipment.carrier] || 'kurir'}.`, + 'success', + ), + updatedById: currentUser.id, + }, + { transaction }, + ); + }); + + return this.getState(currentUser); + } + + static async launchCampaign(currentUser, campaignId) { + await db.sequelize.transaction(async (transaction) => { + await this.ensureDemoData(currentUser, transaction); + + const state = await db.vorta_commerce_states.findOne({ + where: { importHash: makeImportHash('state', currentUser) }, + transaction, + }); + const campaigns = Array.isArray(state.campaigns) ? state.campaigns : cloneJson(DEFAULT_CAMPAIGNS); + const campaign = campaigns.find((item) => item.id === campaignId); + + if (!campaign) throwBadRequest('Campaign tidak ditemukan.'); + + const updatedCampaigns = campaigns.map((item) => ( + item.id === campaignId + ? { + ...item, + status: 'Aktif', + conversion: Number((toNumber(item.conversion) + 0.9).toFixed(1)), + revenue: toNumber(item.revenue) + Math.round(toNumber(item.budget) * 2.4), + } + : item + )); + + await state.update( + { + campaigns: updatedCampaigns, + ledger: this.pushLedger( + state.ledger, + 'Campaign diluncurkan', + `${campaign.name} aktif dengan budget IDR ${toNumber(campaign.budget).toLocaleString('id-ID')}.`, + 'success', + ), + updatedById: currentUser.id, + }, + { transaction }, + ); + }); + + return this.getState(currentUser); + } + + static async closeDailyReport(currentUser) { + await db.sequelize.transaction(async (transaction) => { + await this.ensureDemoData(currentUser, transaction); + + const stateBefore = await this.buildState(currentUser); + const state = await db.vorta_commerce_states.findOne({ + where: { importHash: makeImportHash('state', currentUser) }, + transaction, + }); + const paidRevenue = stateBefore.payments + .filter((payment) => payment.status === 'Berhasil') + .reduce((total, payment) => total + payment.amount, 0); + const lowStockCount = stateBefore.products + .filter((product) => product.stock <= product.reorderPoint) + .length; + + await state.update( + { + ledger: this.pushLedger( + state.ledger, + 'Laporan harian dikunci', + `${stateBefore.orders.length} pesanan, IDR ${paidRevenue.toLocaleString('id-ID')} revenue lunas, ${lowStockCount} SKU perlu perhatian.`, + 'info', + ), + updatedById: currentUser.id, + }, + { transaction }, + ); + }); + + return this.getState(currentUser); + } + + static async updateSettings(currentUser, updates) { + await db.sequelize.transaction(async (transaction) => { + await this.ensureDemoData(currentUser, transaction); + + const state = await db.vorta_commerce_states.findOne({ + where: { importHash: makeImportHash('state', currentUser) }, + transaction, + }); + const settings = { + ...DEFAULT_SETTINGS, + ...(state.settings || {}), + ...updates, + }; + const store = state.storeId ? await db.stores.findByPk(state.storeId, { transaction }) : null; + + if (store && updates.storeName) { + await store.update( + { + store_name: updates.storeName, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + await state.update( + { + settings, + ledger: this.pushLedger(state.ledger, 'Pengaturan commerce diperbarui', 'Konfigurasi toko disimpan ke database.', 'info'), + updatedById: currentUser.id, + }, + { transaction }, + ); + }); + + return this.getState(currentUser); + } + + static async currentStoreId(currentUser, transaction) { + const state = await db.vorta_commerce_states.findOne({ + where: { importHash: makeImportHash('state', currentUser) }, + transaction, + }); + + if (!state?.storeId) throwBadRequest('Store VORTA-COMMERCE tidak ditemukan.'); + + return state.storeId; + } +} + +module.exports = VortaCommerceService; diff --git a/backend/src/services/vortaUniverse.js b/backend/src/services/vortaUniverse.js new file mode 100644 index 0000000..40b424c --- /dev/null +++ b/backend/src/services/vortaUniverse.js @@ -0,0 +1,763 @@ +const bcrypt = require('bcrypt'); +const db = require('../db/models'); + +const DEFAULT_PROFILE = { + businessName: 'VORTA Nexus Demo', + trustScore: 50, + verified: false, + walletBalance: 1850000, + geoseekRadiusKm: 7, + aiQuota: 32, + phone: '', + phoneVerified: false, +}; + +const DEFAULT_SETTINGS = { + trustAutoSync: true, + marketplaceMode: 'Demo Aktif', + riskReview: 'Medium', +}; + +const DEFAULT_MODULES = [ + { + id: 'business-chat', + icon: '๐Ÿ’ฌ', + name: 'Chat Bisnis', + description: 'Inbox komersial untuk buyer, seller, partner, dan tim internal.', + metric: '128 percakapan', + status: 'Online', + trustImpact: 2, + launches: 0, + }, + { + id: 'marketplace', + icon: '๐Ÿ›’', + name: 'Marketplace', + description: 'Etalase produk, seller score, katalog cepat, dan aktivitas listing.', + metric: '24 listing aktif', + status: 'Online', + trustImpact: 3, + launches: 0, + }, + { + id: 'wallet', + icon: '๐Ÿ’ณ', + name: 'Wallet', + description: 'Saldo, pembayaran, reward, dan settlement untuk pengguna VORTA.', + metric: 'Rp1,85 jt saldo', + status: 'Online', + trustImpact: 2, + launches: 0, + }, + { + id: 'geoseek', + icon: '๐ŸŒ', + name: 'GeoSeek', + description: 'Pencarian peluang, mitra, dan kebutuhan berdasarkan lokasi.', + metric: '7 km radius', + status: 'Beta', + trustImpact: 1, + launches: 0, + }, + { + id: 'vorta-ai', + icon: '๐Ÿค–', + name: 'VORTA AI', + description: 'Asisten AI untuk ide bisnis, analisis produk, dan rekomendasi aksi.', + metric: '32 prompt tersisa', + status: 'Siap', + trustImpact: 2, + launches: 0, + }, + { + id: 'trust-system', + icon: 'โญ', + name: 'Trust System', + description: 'Nexus Trust Score, validasi profil, reputasi transaksi, dan sinyal risiko.', + metric: 'Score dasar 50', + status: 'Aktif', + trustImpact: 4, + launches: 0, + }, +]; + +const DEFAULT_PRODUCTS = [ + { + id: 'VU-PRD-001', + name: 'VORTA Starter Kit', + price: 149000, + seller: 'Nexus Seller Lab', + category: 'Digital Toolkit', + stock: 120, + }, + { + id: 'VU-PRD-002', + name: 'GeoSeek Local Leads', + price: 99000, + seller: 'VORTA Data Market', + category: 'Lead Pack', + stock: 45, + }, + { + id: 'VU-PRD-003', + name: 'Trust Boost Verification', + price: 199000, + seller: 'VORTA Trust Desk', + category: 'Verification', + stock: 18, + }, +]; + +const DEFAULT_LEDGER = [ + { + id: 'VU-LOG-1', + title: 'Nexus Trust Score aktif', + detail: 'Baseline score dimulai dari 50 dan tersimpan di database PostgreSQL.', + time: 'Baru saja', + tone: 'success', + }, + { + id: 'VU-LOG-2', + title: 'Marketplace demo tersambung', + detail: 'Produk awal siap diuji tanpa membuat aplikasi Express/SQLite terpisah.', + time: 'Baru saja', + tone: 'info', + }, +]; + +const clamp = (value, min, max) => Math.min(Math.max(value, min), max); +const cloneJson = (value) => JSON.parse(JSON.stringify(value)); + +const buildHttpError = (message, code = 400) => { + const error = new Error(message); + error.code = code; + return error; +}; + +const toNumber = (value, fallback = 0) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const getOrganizationId = (currentUser) => currentUser?.organizationId || currentUser?.organizationsId || null; + +const makeImportHash = (scope, currentUser) => { + const userId = currentUser?.id || 'anonymous'; + const organizationId = getOrganizationId(currentUser) || 'global'; + return `vorta-universe-${organizationId}-${userId}-${scope}`; +}; + +const nowLabel = () => new Intl.DateTimeFormat('id-ID', { + dateStyle: 'medium', + timeStyle: 'short', +}).format(new Date()); + +const getDisplayName = (currentUser) => { + const firstName = currentUser?.firstName || ''; + const lastName = currentUser?.lastName || ''; + const fullName = `${firstName} ${lastName}`.trim(); + + return fullName || currentUser?.email || 'Founder VORTA'; +}; + +const getScopedWhere = (currentUser) => ({ + userId: currentUser?.id || null, + organizationsId: getOrganizationId(currentUser), +}); + +const normalizePhone = (phone) => String(phone || '').replace(/[^0-9+]/g, '').trim(); + +const makeOtp = () => String(Math.floor(100000 + Math.random() * 900000)); + + +class VortaUniverseService { + static normalizeState(record) { + const profile = { + ...cloneJson(DEFAULT_PROFILE), + ...(record.profile || {}), + }; + + const modules = Array.isArray(record.modules) && record.modules.length + ? record.modules + : cloneJson(DEFAULT_MODULES); + const products = Array.isArray(record.products) && record.products.length + ? record.products + : cloneJson(DEFAULT_PRODUCTS); + const ledger = Array.isArray(record.ledger) && record.ledger.length + ? record.ledger + : cloneJson(DEFAULT_LEDGER); + + return { + profile: { + ...profile, + trustScore: clamp(toNumber(profile.trustScore, DEFAULT_PROFILE.trustScore), 0, 100), + verified: Boolean(profile.verified || toNumber(profile.trustScore, 0) >= 80), + }, + modules, + products, + ledger, + settings: { + ...cloneJson(DEFAULT_SETTINGS), + ...(record.settings || {}), + }, + persistedAt: new Date().toISOString(), + }; + } + + static async ensureState(currentUser, transaction) { + const importHash = makeImportHash('state', currentUser); + const where = { importHash }; + const existing = await db.vorta_universe_states.findOne({ where, transaction }); + + if (existing) { + return existing; + } + + return db.vorta_universe_states.create( + { + userId: currentUser?.id || null, + organizationsId: getOrganizationId(currentUser), + profile: cloneJson(DEFAULT_PROFILE), + modules: cloneJson(DEFAULT_MODULES), + products: cloneJson(DEFAULT_PRODUCTS), + ledger: cloneJson(DEFAULT_LEDGER), + settings: cloneJson(DEFAULT_SETTINGS), + importHash, + createdById: currentUser?.id || null, + updatedById: currentUser?.id || null, + }, + { transaction }, + ); + } + + static async getState(currentUser) { + const state = await db.sequelize.transaction(async (transaction) => this.ensureState(currentUser, transaction)); + return this.normalizeState(state); + } + + static async resetState(currentUser) { + await db.sequelize.transaction(async (transaction) => { + const state = await this.ensureState(currentUser, transaction); + + await state.update( + { + profile: cloneJson(DEFAULT_PROFILE), + modules: cloneJson(DEFAULT_MODULES), + products: cloneJson(DEFAULT_PRODUCTS), + ledger: this.addLedgerItem(cloneJson(DEFAULT_LEDGER), { + title: 'State VORTA di-reset', + detail: 'Profile, module, produk, setting, dan ledger kembali ke data demo awal.', + tone: 'warning', + }), + settings: cloneJson(DEFAULT_SETTINGS), + updatedById: currentUser?.id || null, + }, + { transaction }, + ); + }); + + return this.getState(currentUser); + } + + static addLedgerItem(ledger, item) { + return [ + { + id: `VU-LOG-${Date.now()}`, + time: nowLabel(), + tone: 'info', + ...item, + }, + ...(Array.isArray(ledger) ? ledger : []), + ].slice(0, 12); + } + + static async updateTrustScore(currentUser, payload) { + const requestedScore = toNumber(payload.score, Number.NaN); + + if (!Number.isFinite(requestedScore)) { + throw buildHttpError('Score harus berupa angka 0 sampai 100.'); + } + + const nextScore = clamp(Math.round(requestedScore), 0, 100); + + await db.sequelize.transaction(async (transaction) => { + const state = await this.ensureState(currentUser, transaction); + const current = this.normalizeState(state); + const previousScore = current.profile.trustScore; + + await state.update( + { + profile: { + ...current.profile, + trustScore: nextScore, + verified: nextScore >= 80, + }, + ledger: this.addLedgerItem(current.ledger, { + title: 'Nexus Trust Score diperbarui', + detail: `Score berubah dari ${previousScore} ke ${nextScore}.`, + tone: nextScore >= previousScore ? 'success' : 'warning', + }), + updatedById: currentUser?.id || null, + }, + { transaction }, + ); + }); + + return this.getState(currentUser); + } + + static async runModule(currentUser, moduleId) { + await db.sequelize.transaction(async (transaction) => { + const state = await this.ensureState(currentUser, transaction); + const current = this.normalizeState(state); + const target = current.modules.find((item) => item.id === moduleId); + + if (!target) { + throw buildHttpError('Module VORTA Universe tidak ditemukan.', 404); + } + + const modules = current.modules.map((item) => ( + item.id === moduleId + ? { + ...item, + launches: toNumber(item.launches) + 1, + lastRun: new Date().toISOString(), + status: item.status === 'Beta' ? 'Beta Aktif' : 'Online', + } + : item + )); + const nextScore = clamp(current.profile.trustScore + toNumber(target.trustImpact, 1), 0, 100); + + await state.update( + { + profile: { + ...current.profile, + trustScore: nextScore, + verified: nextScore >= 80, + }, + modules, + ledger: this.addLedgerItem(current.ledger, { + title: `${target.name} dijalankan`, + detail: `Module ${target.name} aktif dan menambah ${toNumber(target.trustImpact, 1)} poin trust.`, + tone: 'success', + }), + updatedById: currentUser?.id || null, + }, + { transaction }, + ); + }); + + return this.getState(currentUser); + } + + static buildProduct(payload, productNumber) { + const name = typeof payload.name === 'string' ? payload.name.trim() : ''; + + if (!name) { + throw buildHttpError('Nama produk wajib diisi.'); + } + + return { + id: payload.id || `VU-PRD-${String(productNumber).padStart(3, '0')}`, + name, + price: clamp(Math.round(toNumber(payload.price, 0)), 0, 999999999), + seller: payload.seller || 'VORTA Universe Seller', + category: payload.category || 'General', + stock: clamp(Math.round(toNumber(payload.stock, 1)), 0, 999999), + }; + } + + static async createProduct(currentUser, payload) { + await db.sequelize.transaction(async (transaction) => { + const state = await this.ensureState(currentUser, transaction); + const current = this.normalizeState(state); + const product = this.buildProduct(payload, current.products.length + 1); + + await state.update( + { + products: [product, ...current.products].slice(0, 20), + ledger: this.addLedgerItem(current.ledger, { + title: 'Produk marketplace ditambahkan', + detail: `${product.name} dari ${product.seller} tersimpan di VORTA Universe.`, + tone: 'success', + }), + updatedById: currentUser?.id || null, + }, + { transaction }, + ); + }); + + return this.getState(currentUser); + } + + + static async addStateLedger(currentUser, item, transaction) { + const state = await this.ensureState(currentUser, transaction); + const current = this.normalizeState(state); + + await state.update( + { + ledger: this.addLedgerItem(current.ledger, item), + updatedById: currentUser?.id || null, + }, + { transaction }, + ); + } + + static serializeComment(comment) { + const row = comment.get ? comment.get({ plain: true }) : comment; + + return { + id: row.id, + post_id: row.post_id, + user: row.user, + comment: row.comment, + parent_id: row.parent_id || '0', + created_at: row.created_at, + }; + } + + static serializePost(post, comments) { + const row = post.get ? post.get({ plain: true }) : post; + + return { + id: row.id, + user: row.user, + content: row.content, + created_at: row.created_at, + comments, + }; + } + + static async ensureDefaultSocialFeed(currentUser, transaction) { + const where = getScopedWhere(currentUser); + const existingPost = await db.vorta_social_posts.findOne({ where, transaction }); + + if (existingPost) { + return; + } + + const post = await db.vorta_social_posts.create( + { + ...where, + user: 'VORTA System', + content: 'Selamat datang di feed VORTA Universe. Ini versi PostgreSQL dari prototype posts SQLite.', + importHash: makeImportHash('social-welcome-post', currentUser), + }, + { transaction }, + ); + + await db.vorta_social_comments.create( + { + ...where, + post_id: post.id, + user: getDisplayName(currentUser), + comment: 'Feed aktif: post, komentar, dan reply siap tersimpan permanen.', + parent_id: '0', + importHash: makeImportHash('social-welcome-comment', currentUser), + }, + { transaction }, + ); + } + + static async buildSocialFeed(currentUser, transaction) { + const where = getScopedWhere(currentUser); + const posts = await db.vorta_social_posts.findAll({ + where, + order: [['created_at', 'DESC']], + limit: 20, + transaction, + }); + const postIds = posts.map((post) => post.id); + const comments = postIds.length + ? await db.vorta_social_comments.findAll({ + where: { + ...where, + post_id: { + [db.Sequelize.Op.in]: postIds, + }, + }, + order: [['created_at', 'ASC']], + transaction, + }) + : []; + + const commentsByPost = comments.reduce((acc, comment) => { + const serialized = this.serializeComment(comment); + acc[serialized.post_id] = acc[serialized.post_id] || []; + acc[serialized.post_id].push(serialized); + return acc; + }, {}); + + return { + posts: posts.map((post) => this.serializePost(post, commentsByPost[post.id] || [])), + stats: { + postsCount: posts.length, + commentsCount: comments.length, + }, + syncedAt: new Date().toISOString(), + }; + } + + static async getSocialFeed(currentUser) { + return db.sequelize.transaction(async (transaction) => { + await this.ensureDefaultSocialFeed(currentUser, transaction); + return this.buildSocialFeed(currentUser, transaction); + }); + } + + static async resetSocialFeed(currentUser) { + await db.sequelize.transaction(async (transaction) => { + const where = getScopedWhere(currentUser); + + await db.vorta_social_comments.destroy({ where, transaction }); + await db.vorta_social_posts.destroy({ where, transaction }); + await this.ensureDefaultSocialFeed(currentUser, transaction); + await this.addStateLedger(currentUser, { + title: 'Social feed VORTA di-reset', + detail: 'Posts dan comments scoped user ini dikembalikan ke feed demo awal.', + tone: 'warning', + }, transaction); + }); + + return { + feed: await this.getSocialFeed(currentUser), + state: await this.getState(currentUser), + }; + } + + static async createSocialPost(currentUser, payload) { + const content = typeof payload.content === 'string' ? payload.content.trim() : ''; + + if (!content) { + throw buildHttpError('Konten post wajib diisi.'); + } + + await db.sequelize.transaction(async (transaction) => { + await this.ensureDefaultSocialFeed(currentUser, transaction); + const where = getScopedWhere(currentUser); + + await db.vorta_social_posts.create( + { + ...where, + user: payload.user || getDisplayName(currentUser), + content, + }, + { transaction }, + ); + + await this.addStateLedger(currentUser, { + title: 'Post komunitas dibuat', + detail: 'Konten baru tersimpan di tabel vorta_social_posts PostgreSQL.', + tone: 'success', + }, transaction); + }); + + return { + feed: await this.getSocialFeed(currentUser), + state: await this.getState(currentUser), + }; + } + + static async createSocialComment(currentUser, postId, payload) { + const commentText = typeof payload.comment === 'string' ? payload.comment.trim() : ''; + + if (!commentText) { + throw buildHttpError('Komentar wajib diisi.'); + } + + await db.sequelize.transaction(async (transaction) => { + const where = getScopedWhere(currentUser); + const post = await db.vorta_social_posts.findOne({ + where: { + ...where, + id: postId, + }, + transaction, + }); + + if (!post) { + throw buildHttpError('Post VORTA tidak ditemukan.', 404); + } + + await db.vorta_social_comments.create( + { + ...where, + post_id: post.id, + user: payload.user || getDisplayName(currentUser), + comment: commentText, + parent_id: payload.parent_id || '0', + }, + { transaction }, + ); + + await this.addStateLedger(currentUser, { + title: 'Komentar komunitas dibuat', + detail: 'Komentar tersimpan di tabel vorta_social_comments PostgreSQL.', + tone: 'info', + }, transaction); + }); + + return { + feed: await this.getSocialFeed(currentUser), + state: await this.getState(currentUser), + }; + } + + static async requestOtp(currentUser, payload) { + const phone = normalizePhone(payload.phone); + + if (!phone || phone.length < 8) { + throw buildHttpError('Nomor HP wajib diisi minimal 8 digit.'); + } + + const otp = makeOtp(); + const otpHash = await bcrypt.hash(otp, 10); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); + + await db.sequelize.transaction(async (transaction) => { + const where = getScopedWhere(currentUser); + + await db.vorta_login_otps.update( + { + status: 'superseded', + updatedById: currentUser?.id || null, + }, + { + where: { + ...where, + phone, + status: 'pending', + }, + transaction, + }, + ); + + await db.vorta_login_otps.create( + { + ...where, + phone, + otpHash, + expiresAt, + importHash: `vorta-otp-${Date.now()}-${Math.random().toString(16).slice(2)}`, + createdById: currentUser?.id || null, + updatedById: currentUser?.id || null, + }, + { transaction }, + ); + + await this.addStateLedger(currentUser, { + title: 'OTP development dibuat', + detail: `Kode OTP untuk ${phone} berlaku 5 menit dan tersimpan sebagai hash.`, + tone: 'info', + }, transaction); + }); + + return { + message: 'OTP dev berhasil dibuat. Untuk produksi, sambungkan endpoint ini ke SMS/WhatsApp provider.', + phone, + devOtp: otp, + expiresAt, + state: await this.getState(currentUser), + }; + } + + static async verifyOtp(currentUser, payload) { + const phone = normalizePhone(payload.phone); + const otp = String(payload.otp || '').trim(); + + if (!phone || !otp) { + throw buildHttpError('Nomor HP dan OTP wajib diisi.'); + } + + await db.sequelize.transaction(async (transaction) => { + const where = getScopedWhere(currentUser); + const record = await db.vorta_login_otps.findOne({ + where: { + ...where, + phone, + status: 'pending', + expiresAt: { + [db.Sequelize.Op.gt]: new Date(), + }, + }, + order: [['createdAt', 'DESC']], + transaction, + }); + + if (!record) { + throw buildHttpError('OTP tidak ditemukan atau sudah kedaluwarsa.', 400); + } + + const isValid = await bcrypt.compare(otp, record.otpHash); + const attempts = toNumber(record.attempts, 0) + 1; + + if (!isValid) { + await record.update( + { + attempts, + status: attempts >= 5 ? 'locked' : 'pending', + updatedById: currentUser?.id || null, + }, + { transaction }, + ); + + throw buildHttpError('OTP salah. Silakan cek kode dan coba lagi.', 400); + } + + await record.update( + { + attempts, + status: 'verified', + verifiedAt: new Date(), + updatedById: currentUser?.id || null, + }, + { transaction }, + ); + + const state = await this.ensureState(currentUser, transaction); + const current = this.normalizeState(state); + const nextScore = clamp(current.profile.trustScore + 5, 0, 100); + + await state.update( + { + profile: { + ...current.profile, + phone, + phoneVerified: true, + trustScore: nextScore, + verified: nextScore >= 80, + }, + ledger: this.addLedgerItem(current.ledger, { + title: 'OTP HP terverifikasi', + detail: `${phone} lolos verifikasi OTP dan menambah 5 poin trust.`, + tone: 'success', + }), + updatedById: currentUser?.id || null, + }, + { transaction }, + ); + }); + + return { + message: 'OTP valid. Nomor HP terverifikasi dan Trust Score naik +5.', + verified: true, + state: await this.getState(currentUser), + }; + } + + static async createDemoProduct(currentUser) { + const productIndex = Date.now().toString().slice(-4); + + return this.createProduct(currentUser, { + id: `VU-PRD-${productIndex}`, + name: `Produk Demo Universe ${productIndex}`, + price: 125000 + (Number(productIndex) % 5) * 25000, + seller: 'VORTA Demo Seller', + category: 'Demo Product', + stock: 24 + (Number(productIndex) % 20), + }); + } +} + +module.exports = VortaUniverseService; diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 4898aad..87bbaee 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -1,7 +1,66 @@ import * as icon from '@mdi/js'; import { MenuAsideItem } from './interfaces'; +const resolveMenuIcon = (name: string) => ( + name in icon ? (icon[name as keyof typeof icon] as string) : icon.mdiViewDashboardOutline +); + const menuAside: MenuAsideItem[] = [ + { + icon: resolveMenuIcon('mdiOrbitVariant'), + label: 'VORTA Universe', + menu: [ + { + href: '/vorta-universe#vorta-overview', + icon: resolveMenuIcon('mdiViewDashboardOutline'), + label: 'Dashboard', + }, + { + href: '/vorta-universe#vorta-auth', + icon: resolveMenuIcon('mdiShieldKeyOutline'), + label: 'Login & OTP', + }, + { + href: '/vorta-universe#vorta-modules', + icon: resolveMenuIcon('mdiApps'), + label: 'Modules', + }, + { + href: '/vorta-universe#vorta-social', + icon: resolveMenuIcon('mdiPostOutline'), + label: 'Feed Bisnis', + }, + { + href: '/vorta-universe#vorta-marketplace', + icon: resolveMenuIcon('mdiStorefrontOutline'), + label: 'Marketplace', + }, + { + href: '/vorta-universe#vorta-settings', + icon: resolveMenuIcon('mdiCogOutline'), + label: 'Settings', + }, + { + href: '/trust_profiles/trust_profiles-list', + icon: resolveMenuIcon('mdiShieldCheck'), + label: 'Nexus Score Entity', + permissions: 'READ_TRUST_PROFILES', + }, + { + href: '/wallets/wallets-list', + icon: resolveMenuIcon('mdiWallet'), + label: 'Wallet Entity', + permissions: 'READ_WALLETS', + }, + { + href: '/products/products-list', + icon: resolveMenuIcon('mdiPackageVariantClosed'), + label: 'Products Entity', + permissions: 'READ_PRODUCTS', + }, + ], + }, + { href: '/vorta-commerce', icon: diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index ff1ea24..834a375 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -32,6 +32,25 @@ type TrustReport = TrustForm & { recommendations: string[]; }; +type SectionLink = { + id: string; + label: string; +}; + +type WorkspaceModule = { + title: string; + description: string; + metric: string; + sectionId: string; +}; + +type Pillar = { + title: string; + description: string; + route: string; + actionLabel: string; +}; + const defaultForm: TrustForm = { profileName: '', profileType: 'umkm', @@ -52,6 +71,72 @@ const profileTypeLabels: Record = { company: 'Perusahaan', }; +const sectionLinks: SectionLink[] = [ + { id: 'home', label: 'Beranda' }, + { id: 'workspace-preview', label: 'Workspace' }, + { id: 'pillars', label: 'Super-App' }, + { id: 'trust-flow', label: 'Skor' }, + { id: 'reports', label: 'Laporan' }, +]; + +const workspaceModules: WorkspaceModule[] = [ + { + title: 'Chat bisnis', + description: 'Buka alur percakapan tim, pembeli, dan penjual.', + metric: '42 pesan', + sectionId: 'pillars', + }, + { + title: 'Feed sosial', + description: 'Lihat komunitas dan reputasi yang terhubung skor Nexus.', + metric: '31 update', + sectionId: 'trust-flow', + }, + { + title: 'Marketplace', + description: 'Jalankan simulasi toko, produk, dan ulasan terverifikasi.', + metric: '18 produk', + sectionId: 'trust-flow', + }, + { + title: 'Dompet digital', + description: 'Pantau dompet, transaksi, dan aktivitas pembayaran ringan.', + metric: '37 transaksi', + sectionId: 'reports', + }, +]; + +const pillars: Pillar[] = [ + { + title: 'Mega Super-App', + description: + 'Chat bisnis, sosial, marketplace, dan dompet dalam satu ruang kerja ringan.', + route: '/mega-super-app', + actionLabel: 'Buka Super-App', + }, + { + title: 'Vorta Nexus', + description: + 'Skor kepercayaan 0-100 untuk identitas, bisnis, ulasan, dan akses komunitas.', + route: '/vorta-universe', + actionLabel: 'Buka Nexus', + }, + { + title: 'Facta.AI', + description: + 'Riset bebas SEO spam dengan Truth Score dan peta rujukan langsung.', + route: '/vorta-commerce', + actionLabel: 'Buka Commerce', + }, + { + title: 'Vorta Synapse', + description: + 'Protokol distribusi, afiliasi, dan logistik pintar untuk ekonomi kreator.', + route: '/vorta-synapse', + actionLabel: 'Buka Synapse', + }, +]; + const getTier = (score: number) => { if (score >= 86) return 'Nexus Prime'; if (score >= 70) return 'Pertumbuhan Terverifikasi'; @@ -150,6 +235,13 @@ export default function VortaLanding() { const [reports, setReports] = useState([]); const [activeReportId, setActiveReportId] = useState(''); const [errorMessage, setErrorMessage] = useState(''); + const [activeWorkspaceModule, setActiveWorkspaceModule] = useState( + workspaceModules[0].title, + ); + const [frontActionMessage, setFrontActionMessage] = useState( + 'Semua menu dan tombol muka siap dijalankan.', + ); + const [activePillarTitle, setActivePillarTitle] = useState(pillars[0].title); useEffect(() => { try { @@ -208,6 +300,9 @@ export default function VortaLanding() { } setErrorMessage(''); + setFrontActionMessage( + `Laporan Kepercayaan untuk ${trimmedName} berhasil dibuat.`, + ); saveReport({ ...form, profileName: trimmedName }); }; @@ -226,40 +321,67 @@ export default function VortaLanding() { setForm(sampleForm); setErrorMessage(''); + setFrontActionMessage('Contoh cepat dibuat dan detail laporan dibuka.'); saveReport(sampleForm); }; const handleResetForm = () => { setForm(defaultForm); setErrorMessage(''); + setFrontActionMessage('Formulir simulator dikembalikan ke nilai awal.'); scrollToSection('trust-flow'); }; + const handleSectionNavigation = (sectionId: string, label: string) => { + setFrontActionMessage(`Menu ${label} dibuka.`); + scrollToSection(sectionId); + }; + + const handleWorkspaceModule = (module: WorkspaceModule) => { + setActiveWorkspaceModule(module.title); + setFrontActionMessage(`${module.title} aktif: ${module.description}`); + scrollToSection(module.sectionId); + }; + + const handlePillarPreview = (pillar: Pillar) => { + setActivePillarTitle(pillar.title); + setFrontActionMessage( + `${pillar.title} dipilih. Tekan "${pillar.actionLabel}" untuk membuka halaman modul.`, + ); + scrollToSection('pillars'); + }; + + const handleStartDemo = () => { + setForm({ + ...defaultForm, + profileName: 'Demo VORTA Universe', + verifiedDocuments: '3', + citedSources: '3', + hasTaxId: true, + verifiedReviews: true, + }); + setErrorMessage(''); + setFrontActionMessage( + 'Demo skor siap. Periksa formulir lalu tekan Buat Laporan Kepercayaan.', + ); + scrollToSection('trust-flow'); + }; + + const handleClearReports = () => { + setReports([]); + setActiveReportId(''); + setFrontActionMessage( + 'Semua riwayat laporan demo di browser ini dibersihkan.', + ); + scrollToSection('reports'); + }; + const stats = [ { label: 'kanal utama', value: '4 mode' }, { label: 'ruang kerja', value: '1 app' }, { label: 'akses ringan', value: '<3 detik' }, ]; - const pillars = [ - [ - 'Mega Super-App', - 'Chat bisnis, sosial, marketplace, dan dompet dalam satu ruang kerja ringan.', - ], - [ - 'Vorta Nexus', - 'Skor kepercayaan 0-100 untuk identitas, bisnis, ulasan, dan akses komunitas.', - ], - [ - 'Facta.AI', - 'Riset bebas SEO spam dengan Truth Score dan peta rujukan langsung.', - ], - [ - 'Vorta Synapse', - 'Protokol distribusi, afiliasi, dan logistik pintar untuk ekonomi kreator.', - ], - ]; - return ( <> @@ -270,7 +392,10 @@ export default function VortaLanding() { /> -
+
@@ -286,18 +411,14 @@ export default function VortaLanding() { VORTA UNIVERSE
- {[ - ['pillars', 'Super-App'], - ['trust-flow', 'Skor'], - ['reports', 'Laporan'], - ].map(([sectionId, label]) => ( + {sectionLinks.map((item) => ( ))}
@@ -311,18 +432,14 @@ export default function VortaLanding() {
- {[ - ['pillars', 'Super-App'], - ['trust-flow', 'Skor'], - ['reports', 'Laporan'], - ].map(([sectionId, label]) => ( + {sectionLinks.map((item) => ( ))}
@@ -343,15 +460,17 @@ export default function VortaLanding() {

scrollToSection('pillars')} + onClick={() => + handleSectionNavigation('pillars', 'Alur Super-App') + } label='Lihat Alur Super-App' color='success' roundedFull className='border-emerald-300 bg-[#1DE9B6] px-7 py-3 text-slate-950 hover:bg-white' /> scrollToSection('trust-flow')} - label='Simulasikan Kepercayaan' + onClick={handleStartDemo} + label='Jalankan Demo Skor' color='white' outline roundedFull @@ -366,6 +485,9 @@ export default function VortaLanding() { className='border-white/30 bg-white/5 px-7 py-3 text-white hover:bg-white hover:text-slate-950' />
+
+ Status: {frontActionMessage} +
{stats.map((stat) => (
-
+
- {[ - 'Chat bisnis', - 'Feed sosial', - 'Marketplace', - 'Dompet digital', - ].map((item) => ( -
( + ))}

- Aktivitas terpadu hari ini + Modul aktif: {activeWorkspaceModule}

128

-

- Transaksi & pesan sinkron -

+
@@ -456,24 +594,57 @@ export default function VortaLanding() { Satu ruang kerja ringan yang menghubungkan percakapan, komunitas, perdagangan, pembayaran, dan reputasi pengguna. +

+ Pilar aktif: {activePillarTitle} +

- {pillars.map(([title, description], index) => ( + {pillars.map((pillar, index) => (
-
- 0{index + 1} + +
+ + handlePillarPreview(pillar)} + label='Preview di halaman ini' + color='white' + outline + roundedFull + small + className='border-white/20 bg-transparent text-slate-200 hover:bg-white/10' + />
-

{title}

-

- {description} -

))}
@@ -490,9 +661,9 @@ export default function VortaLanding() { Skor Kepercayaan untuk Super-App

- Setiap akun, toko, percakapan, ulasan, dan transaksi bisa - diberi sinyal kepercayaan agar marketplace dan komunitas tetap - aman tanpa terasa berat bagi pengguna. + Setiap akun, toko, percakapan, ulasan, dan transaksi bisa diberi + sinyal kepercayaan agar marketplace dan komunitas tetap aman + tanpa terasa berat bagi pengguna.

Aturan validasi: nama @@ -656,6 +827,26 @@ export default function VortaLanding() {

Pilih laporan untuk membuka detail simulasi.

+
+ + +
{reports.length ? ( @@ -792,6 +983,13 @@ export default function VortaLanding() { Buat Laporan Kepercayaan pertama untuk melihat tier, progress bar, dan rekomendasi.

+
)} diff --git a/frontend/src/pages/vorta-commerce.tsx b/frontend/src/pages/vorta-commerce.tsx index 91c2d0f..4749305 100644 --- a/frontend/src/pages/vorta-commerce.tsx +++ b/frontend/src/pages/vorta-commerce.tsx @@ -1,6 +1,7 @@ import * as icon from '@mdi/js'; +import axios from 'axios'; import Head from 'next/head'; -import React, { ReactElement, useMemo, useState } from 'react'; +import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; import BaseButton from '../components/BaseButton'; import BaseIcon from '../components/BaseIcon'; import CardBox from '../components/CardBox'; @@ -107,6 +108,20 @@ type Settings = { paymentAutoCapture: boolean; }; +type CommerceState = { + products: Product[]; + customers: Customer[]; + orders: Order[]; + payments: Payment[]; + shipments: Shipment[]; + campaigns: Campaign[]; + ledger: LedgerItem[]; + settings: Settings; + persistedAt?: string; +}; + +type CommerceAction = () => Promise<{ data: CommerceState }>; + const initialProducts: Product[] = [ { id: 'SKU-EL-001', @@ -416,6 +431,56 @@ const VortaCommercePage = () => { const [campaigns, setCampaigns] = useState(initialCampaigns); const [ledger, setLedger] = useState(initialLedger); const [settings, setSettings] = useState(initialSettings); + const [isLoadingCommerce, setIsLoadingCommerce] = useState(true); + const [isSavingCommerce, setIsSavingCommerce] = useState(false); + const [commerceError, setCommerceError] = useState(null); + const [persistedAt, setPersistedAt] = useState(null); + + const applyCommerceState = useCallback((payload: CommerceState) => { + setProducts(payload.products || initialProducts); + setCustomers(payload.customers || initialCustomers); + setOrders(payload.orders || initialOrders); + setPayments(payload.payments || initialPayments); + setShipments(payload.shipments || initialShipments); + setCampaigns(payload.campaigns || initialCampaigns); + setLedger(payload.ledger || initialLedger); + setSettings(payload.settings || initialSettings); + setPersistedAt(payload.persistedAt || new Date().toISOString()); + }, []); + + const loadCommerceState = useCallback(async () => { + setIsLoadingCommerce(true); + setCommerceError(null); + + try { + const response = await axios.get('/vorta-commerce/state'); + applyCommerceState(response.data); + } catch (error) { + console.error('Failed to load VORTA-COMMERCE state:', error); + setCommerceError('Data database belum bisa dimuat. Fallback demo lokal tetap ditampilkan.'); + } finally { + setIsLoadingCommerce(false); + } + }, [applyCommerceState]); + + useEffect(() => { + loadCommerceState(); + }, [loadCommerceState]); + + const runCommerceAction = useCallback(async (action: CommerceAction, errorMessage: string) => { + setIsSavingCommerce(true); + setCommerceError(null); + + try { + const response = await action(); + applyCommerceState(response.data); + } catch (error) { + console.error(errorMessage, error); + setCommerceError(`${errorMessage} Cek backend log untuk detail.`); + } finally { + setIsSavingCommerce(false); + } + }, [applyCommerceState]); const metrics = useMemo(() => { const paidRevenue = payments @@ -459,223 +524,75 @@ const VortaCommercePage = () => { [products], ); - const pushLedger = (title: string, detail: string, tone: LedgerItem['tone'] = 'info') => { - setLedger((current) => [ - { - id: `LOG-${Date.now()}`, - title, - detail, - time: 'Baru saja', - tone, - }, - ...current, - ].slice(0, 8)); - }; - const restockLowStock = () => { - const lowStockProducts = products.filter((product) => product.stock <= product.reorderPoint); - - if (!lowStockProducts.length) { - pushLedger('Inventori aman', 'Tidak ada produk di bawah reorder point saat ini.', 'success'); - return; - } - - setProducts((current) => - current.map((product) => - product.stock <= product.reorderPoint - ? { ...product, stock: product.stock + product.reorderPack } - : product, - ), - ); - pushLedger( - 'Auto-restock dibuat', - `${lowStockProducts.length} SKU masuk purchase order otomatis dari inventori.`, - 'success', + runCommerceAction( + () => axios.post('/vorta-commerce/inventory/restock'), + 'Gagal menjalankan restock cepat.', ); }; const createFlashOrder = () => { - const product = products.find((item) => item.id === 'SKU-BE-117') || products[0]; - const quantity = 3; - - if (product.stock < quantity) { - pushLedger('Order gagal dibuat', `${product.name} tidak punya stok cukup.`, 'danger'); - return; - } - - const orderNumber = orders.length + 24061; - const paymentNumber = payments.length + 8801; - const shipmentNumber = shipments.length + 7101; - const total = product.price * quantity + 25000; - const nextOrder: Order = { - id: `ORD-${orderNumber}`, - customerId: 'CUST-002', - total, - status: 'Baru', - paymentStatus: 'Menunggu', - shipmentStatus: 'Menunggu Pick Up', - channel: 'Social Commerce', - createdAt: 'Baru saja', - items: [`${product.name} x${quantity}`], - }; - - setProducts((current) => - current.map((item) => - item.id === product.id - ? { ...item, stock: item.stock - quantity, sold: item.sold + quantity } - : item, - ), + runCommerceAction( + () => axios.post('/vorta-commerce/orders/demo'), + 'Gagal membuat order demo.', ); - setOrders((current) => [nextOrder, ...current]); - setPayments((current) => [ - { - id: `PAY-${paymentNumber}`, - orderId: nextOrder.id, - method: 'QRIS', - amount: total, - status: 'Menunggu', - }, - ...current, - ]); - setShipments((current) => [ - { - id: `SHP-${shipmentNumber}`, - orderId: nextOrder.id, - destination: 'Bandung', - courier: 'Vorta Express', - eta: '6 jam', - status: 'Menunggu Pick Up', - risk: 'Rendah', - }, - ...current, - ]); - setCustomers((current) => - current.map((customer) => - customer.id === 'CUST-002' - ? { - ...customer, - orders: customer.orders + 1, - lifetimeValue: customer.lifetimeValue + total, - } - : customer, - ), - ); - pushLedger('Flash order dibuat', `${nextOrder.id} dibuat dari Social Commerce.`, 'success'); }; const capturePayment = (paymentId: string) => { - const payment = payments.find((item) => item.id === paymentId); - - if (!payment || payment.status === 'Berhasil') return; - - setPayments((current) => - current.map((item) => (item.id === paymentId ? { ...item, status: 'Berhasil' } : item)), + runCommerceAction( + () => axios.post(`/vorta-commerce/payments/${paymentId}/capture`), + 'Gagal capture payment.', ); - setOrders((current) => - current.map((order) => - order.id === payment.orderId - ? { ...order, paymentStatus: 'Berhasil', status: order.status === 'Baru' ? 'Diproses' : order.status } - : order, - ), - ); - pushLedger('Pembayaran sukses', `${payment.orderId} ditandai lunas via ${payment.method}.`, 'success'); }; const advanceOrder = (orderId: string) => { - const order = orders.find((item) => item.id === orderId); - - if (!order) return; - - const nextStatus: Record = { - Baru: 'Diproses', - Diproses: 'Dikirim', - Dikirim: 'Selesai', - Selesai: 'Selesai', - }; - const next = nextStatus[order.status]; - - setOrders((current) => - current.map((item) => - item.id === orderId - ? { - ...item, - status: next, - shipmentStatus: - next === 'Dikirim' ? 'Dikirim' : next === 'Selesai' ? 'Terkirim' : item.shipmentStatus, - } - : item, - ), + runCommerceAction( + () => axios.post(`/vorta-commerce/orders/${orderId}/advance`), + 'Gagal memajukan status order.', ); - setShipments((current) => - current.map((shipment) => - shipment.orderId === orderId - ? { - ...shipment, - status: - next === 'Dikirim' ? 'Dikirim' : next === 'Selesai' ? 'Terkirim' : shipment.status, - } - : shipment, - ), - ); - pushLedger('Status pesanan diperbarui', `${orderId} berpindah ke status ${next}.`, 'info'); }; const completeShipment = (shipmentId: string) => { - const shipment = shipments.find((item) => item.id === shipmentId); - - if (!shipment || shipment.status === 'Terkirim') return; - - setShipments((current) => - current.map((item) => (item.id === shipmentId ? { ...item, status: 'Terkirim', eta: 'Selesai' } : item)), + runCommerceAction( + () => axios.post(`/vorta-commerce/shipments/${shipmentId}/complete`), + 'Gagal menandai shipment terkirim.', ); - setOrders((current) => - current.map((order) => - order.id === shipment.orderId - ? { ...order, status: 'Selesai', shipmentStatus: 'Terkirim' } - : order, - ), - ); - pushLedger('Paket terkirim', `${shipment.orderId} selesai oleh ${shipment.courier}.`, 'success'); }; const launchCampaign = (campaignId: string) => { - const campaign = campaigns.find((item) => item.id === campaignId); - - if (!campaign) return; - - setCampaigns((current) => - current.map((item) => - item.id === campaignId - ? { - ...item, - status: 'Aktif', - conversion: Number((item.conversion + 0.9).toFixed(1)), - revenue: item.revenue + Math.round(item.budget * 2.4), - } - : item, - ), + runCommerceAction( + () => axios.post(`/vorta-commerce/campaigns/${campaignId}/launch`), + 'Gagal launch campaign.', ); - pushLedger('Campaign diluncurkan', `${campaign.name} aktif dengan budget ${formatCurrency(campaign.budget)}.`, 'success'); }; const closeDailyReport = () => { - pushLedger( - 'Laporan harian dikunci', - `${orders.length} pesanan, ${formatCurrency(metrics.paidRevenue)} revenue lunas, ${metrics.lowStockCount} SKU perlu perhatian.`, - 'info', + runCommerceAction( + () => axios.post('/vorta-commerce/reports/close'), + 'Gagal mengunci laporan harian.', ); }; const toggleFulfillmentMode = () => { - setSettings((current) => ({ - ...current, - fulfillmentMode: current.fulfillmentMode === 'Otomatis' ? 'Manual Review' : 'Otomatis', - })); - pushLedger('Pengaturan fulfillment berubah', 'Mode fulfillment toko diperbarui.', 'info'); + runCommerceAction( + () => axios.patch('/vorta-commerce/settings', { + data: { + fulfillmentMode: settings.fulfillmentMode === 'Otomatis' ? 'Manual Review' : 'Otomatis', + }, + }), + 'Gagal mengubah fulfillment mode.', + ); }; const toggleBooleanSetting = (key: 'lowStockAlert' | 'paymentAutoCapture') => { - setSettings((current) => ({ ...current, [key]: !current[key] })); + runCommerceAction( + () => axios.patch('/vorta-commerce/settings', { + data: { + [key]: !settings[key], + }, + }), + 'Gagal menyimpan automation setting.', + ); }; const renderMetricCard = (label: string, value: string, helper: string, iconName: string, accent: string) => ( @@ -972,7 +889,7 @@ const VortaCommercePage = () => { icon={resolveIcon('mdiArrowRightCircleOutline')} color={order.status === 'Selesai' ? 'success' : 'info'} small - disabled={order.status === 'Selesai'} + disabled={order.status === 'Selesai' || isSavingCommerce} onClick={() => advanceOrder(order.id)} />
@@ -1040,7 +957,7 @@ const VortaCommercePage = () => { icon={resolveIcon('mdiCheckCircleOutline')} color={payment.status === 'Berhasil' ? 'success' : 'info'} small - disabled={payment.status === 'Berhasil'} + disabled={payment.status === 'Berhasil' || isSavingCommerce} onClick={() => capturePayment(payment.id)} />
@@ -1075,7 +992,7 @@ const VortaCommercePage = () => { icon={resolveIcon('mdiTruckCheckOutline')} color={shipment.status === 'Terkirim' ? 'success' : 'info'} small - disabled={shipment.status === 'Terkirim'} + disabled={shipment.status === 'Terkirim' || isSavingCommerce} onClick={() => completeShipment(shipment.id)} />
@@ -1141,6 +1058,7 @@ const VortaCommercePage = () => { icon={resolveIcon('mdiFileChartOutline')} color="info" small + disabled={isSavingCommerce} onClick={() => closeDailyReport()} />
@@ -1214,6 +1132,7 @@ const VortaCommercePage = () => { icon={resolveIcon('mdiSwapHorizontal')} color="info" small + disabled={isSavingCommerce} onClick={() => toggleFulfillmentMode()} />
@@ -1233,6 +1152,7 @@ const VortaCommercePage = () => { label={settings.lowStockAlert ? 'Aktif' : 'Nonaktif'} color={settings.lowStockAlert ? 'success' : 'warning'} small + disabled={isSavingCommerce} onClick={() => toggleBooleanSetting('lowStockAlert')} />
@@ -1245,6 +1165,7 @@ const VortaCommercePage = () => { label={settings.paymentAutoCapture ? 'Aktif' : 'Nonaktif'} color={settings.paymentAutoCapture ? 'success' : 'warning'} small + disabled={isSavingCommerce} onClick={() => toggleBooleanSetting('paymentAutoCapture')} />
@@ -1279,14 +1200,26 @@ const VortaCommercePage = () => { main > + {commerceError ? ( +
+ {commerceError} +
+ ) : null} + + {(isLoadingCommerce || isSavingCommerce) ? ( +
+ {isLoadingCommerce ? 'Memuat data VORTA-COMMERCE dari database...' : 'Menyimpan perubahan ke database...'} +
+ ) : null} +
@@ -1300,12 +1233,14 @@ const VortaCommercePage = () => { label="Buat Order Demo" icon={resolveIcon('mdiCartPlus')} color="success" + disabled={isSavingCommerce} onClick={() => createFlashOrder()} /> restockLowStock()} /> {
Mode{settings.fulfillmentMode}
Open orders{metrics.openOrders}
Low stock{metrics.lowStockCount}
+
Sync{persistedAt ? 'Persisten' : 'Lokal'}
diff --git a/frontend/src/pages/vorta-universe.tsx b/frontend/src/pages/vorta-universe.tsx new file mode 100644 index 0000000..98ad9cd --- /dev/null +++ b/frontend/src/pages/vorta-universe.tsx @@ -0,0 +1,1368 @@ +import * as icon from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { getPageTitle } from '../config'; +import { useAppSelector } from '../stores/hooks'; + +type UniverseProfile = { + businessName: string; + trustScore: number; + verified: boolean; + walletBalance: number; + geoseekRadiusKm: number; + aiQuota: number; + phone?: string; + phoneVerified?: boolean; +}; + +type UniverseModule = { + id: string; + icon: string; + name: string; + description: string; + metric: string; + status: string; + trustImpact: number; + launches: number; + lastRun?: string; +}; + +type UniverseProduct = { + id: string; + name: string; + price: number; + seller: string; + category: string; + stock: number; +}; + +type LedgerItem = { + id: string; + title: string; + detail: string; + time: string; + tone: 'info' | 'success' | 'warning' | 'danger'; +}; + +type UniverseSettings = { + trustAutoSync: boolean; + marketplaceMode: string; + riskReview: string; +}; + +type UniverseState = { + profile: UniverseProfile; + modules: UniverseModule[]; + products: UniverseProduct[]; + ledger: LedgerItem[]; + settings: UniverseSettings; + persistedAt?: string; +}; + +type SocialComment = { + id: string; + post_id: string; + user: string; + comment: string; + parent_id: string; + created_at: string; +}; + +type SocialPost = { + id: string; + user: string; + content: string; + created_at: string; + comments: SocialComment[]; +}; + +type SocialFeed = { + posts: SocialPost[]; + stats: { + postsCount: number; + commentsCount: number; + }; + syncedAt?: string; +}; + +type SocialMutationResponse = { + feed: SocialFeed; + state: UniverseState; +}; + +type QuickMenuItem = { + id: string; + label: string; + icon: string; + description: string; +}; + +type ProductDraft = { + name: string; + seller: string; + category: string; + price: string; + stock: string; +}; + +type OtpRequestResponse = { + message: string; + phone: string; + devOtp: string; + expiresAt: string; + state: UniverseState; +}; + +type OtpVerifyResponse = { + message: string; + verified: boolean; + state: UniverseState; +}; + +type UniverseAction = () => Promise<{ data: UniverseState }>; +type SocialAction = () => Promise<{ data: SocialMutationResponse }>; + +const fallbackIcon = icon.mdiViewDashboardOutline; +const resolveIcon = (name: string) => ( + name in icon ? icon[name as keyof typeof icon] : fallbackIcon +) as string; + +const initialUniverseState: UniverseState = { + profile: { + businessName: 'VORTA Nexus Demo', + trustScore: 50, + verified: false, + walletBalance: 1850000, + geoseekRadiusKm: 7, + aiQuota: 32, + phone: '', + phoneVerified: false, + }, + modules: [ + { + id: 'business-chat', + icon: '๐Ÿ’ฌ', + name: 'Chat Bisnis', + description: 'Inbox komersial untuk buyer, seller, partner, dan tim internal.', + metric: '128 percakapan', + status: 'Online', + trustImpact: 2, + launches: 0, + }, + { + id: 'marketplace', + icon: '๐Ÿ›’', + name: 'Marketplace', + description: 'Etalase produk, seller score, katalog cepat, dan aktivitas listing.', + metric: '24 listing aktif', + status: 'Online', + trustImpact: 3, + launches: 0, + }, + { + id: 'wallet', + icon: '๐Ÿ’ณ', + name: 'Wallet', + description: 'Saldo, pembayaran, reward, dan settlement untuk pengguna VORTA.', + metric: 'Rp1,85 jt saldo', + status: 'Online', + trustImpact: 2, + launches: 0, + }, + { + id: 'geoseek', + icon: '๐ŸŒ', + name: 'GeoSeek', + description: 'Pencarian peluang, mitra, dan kebutuhan berdasarkan lokasi.', + metric: '7 km radius', + status: 'Beta', + trustImpact: 1, + launches: 0, + }, + { + id: 'vorta-ai', + icon: '๐Ÿค–', + name: 'VORTA AI', + description: 'Asisten AI untuk ide bisnis, analisis produk, dan rekomendasi aksi.', + metric: '32 prompt tersisa', + status: 'Siap', + trustImpact: 2, + launches: 0, + }, + { + id: 'trust-system', + icon: 'โญ', + name: 'Trust System', + description: 'Nexus Trust Score, validasi profil, reputasi transaksi, dan sinyal risiko.', + metric: 'Score dasar 50', + status: 'Aktif', + trustImpact: 4, + launches: 0, + }, + ], + products: [ + { + id: 'VU-PRD-001', + name: 'VORTA Starter Kit', + price: 149000, + seller: 'Nexus Seller Lab', + category: 'Digital Toolkit', + stock: 120, + }, + { + id: 'VU-PRD-002', + name: 'GeoSeek Local Leads', + price: 99000, + seller: 'VORTA Data Market', + category: 'Lead Pack', + stock: 45, + }, + { + id: 'VU-PRD-003', + name: 'Trust Boost Verification', + price: 199000, + seller: 'VORTA Trust Desk', + category: 'Verification', + stock: 18, + }, + ], + ledger: [ + { + id: 'VU-LOG-1', + title: 'Nexus Trust Score aktif', + detail: 'Baseline score dimulai dari 50 dan tersimpan di database PostgreSQL.', + time: 'Baru saja', + tone: 'success', + }, + { + id: 'VU-LOG-2', + title: 'Marketplace demo tersambung', + detail: 'Produk awal siap diuji tanpa membuat aplikasi Express/SQLite terpisah.', + time: 'Baru saja', + tone: 'info', + }, + ], + settings: { + trustAutoSync: true, + marketplaceMode: 'Demo Aktif', + riskReview: 'Medium', + }, +}; + +const initialSocialFeed: SocialFeed = { + posts: [], + stats: { + postsCount: 0, + commentsCount: 0, + }, +}; + +const initialProductDraft: ProductDraft = { + name: 'Produk Custom VORTA', + seller: 'VORTA Universe Seller', + category: 'Custom Product', + price: '175000', + stock: '30', +}; + +const quickMenuItems: QuickMenuItem[] = [ + { + id: 'vorta-overview', + label: 'Overview', + icon: 'mdiViewDashboardOutline', + description: 'Ringkasan trust, wallet, AI, GeoSeek, dan marketplace.', + }, + { + id: 'vorta-auth', + label: 'Auth & OTP', + icon: 'mdiShieldKeyOutline', + description: 'Login Google/email, daftar akun, profil, dan OTP dev.', + }, + { + id: 'vorta-modules', + label: 'Modules', + icon: 'mdiApps', + description: 'Jalankan module Universe dan buka entity terkait.', + }, + { + id: 'vorta-social', + label: 'Social Feed', + icon: 'mdiPostOutline', + description: 'Post, komentar, refresh feed, dan sinkronisasi PostgreSQL.', + }, + { + id: 'vorta-marketplace', + label: 'Marketplace', + icon: 'mdiStorefrontOutline', + description: 'Produk demo/custom dan tabel inventory Universe.', + }, + { + id: 'vorta-settings', + label: 'Settings', + icon: 'mdiCogOutline', + description: 'Mode marketplace, risk review, dan timestamp persistensi.', + }, +]; + +const moduleEntityLinks: Record = { + 'business-chat': '/chats/chats-list', + marketplace: '/products/products-list', + wallet: '/wallets/wallets-list', + geoseek: '/geo_rules/geo_rules-list', + 'vorta-ai': '/facta_queries/facta_queries-list', + 'trust-system': '/trust_profiles/trust_profiles-list', +}; + +const formatCurrency = (value: number) => new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + maximumFractionDigits: 0, +}).format(value); + +const formatNumber = (value: number) => new Intl.NumberFormat('id-ID', { + maximumFractionDigits: 0, +}).format(value); + +const formatDateTime = (value?: string) => { + if (!value) return 'Baru saja'; + + return new Intl.DateTimeFormat('id-ID', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +}; + +const getToneClass = (tone: LedgerItem['tone']) => { + if (tone === 'success') return 'bg-emerald-500'; + if (tone === 'warning') return 'bg-amber-500'; + if (tone === 'danger') return 'bg-red-500'; + + return 'bg-blue-500'; +}; + +const getTrustLabel = (score: number) => { + if (score >= 80) return 'Verified Nexus'; + if (score >= 65) return 'Trusted Growth'; + if (score >= 45) return 'Starter Trust'; + + return 'Perlu Review'; +}; + +const VortaUniversePage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const iconsColor = useAppSelector((state) => state.style.iconsColor); + const [profile, setProfile] = useState(initialUniverseState.profile); + const [modules, setModules] = useState(initialUniverseState.modules); + const [products, setProducts] = useState(initialUniverseState.products); + const [ledger, setLedger] = useState(initialUniverseState.ledger); + const [settings, setSettings] = useState(initialUniverseState.settings); + const [persistedAt, setPersistedAt] = useState(null); + const [isLoadingUniverse, setIsLoadingUniverse] = useState(true); + const [isSavingUniverse, setIsSavingUniverse] = useState(false); + const [universeError, setUniverseError] = useState(null); + const [socialFeed, setSocialFeed] = useState(initialSocialFeed); + const [postContent, setPostContent] = useState(''); + const [commentDrafts, setCommentDrafts] = useState>({}); + const [isLoadingSocial, setIsLoadingSocial] = useState(true); + const [isSavingSocial, setIsSavingSocial] = useState(false); + const [socialError, setSocialError] = useState(null); + const [otpPhone, setOtpPhone] = useState(profile.phone || ''); + const [otpCode, setOtpCode] = useState(''); + const [otpStatus, setOtpStatus] = useState(null); + const [otpDevCode, setOtpDevCode] = useState(null); + const [isOtpBusy, setIsOtpBusy] = useState(false); + const [productDraft, setProductDraft] = useState(initialProductDraft); + const [universeNotice, setUniverseNotice] = useState(null); + const [socialNotice, setSocialNotice] = useState(null); + + const applyUniverseState = useCallback((payload: UniverseState) => { + setProfile(payload.profile || initialUniverseState.profile); + setModules(payload.modules || initialUniverseState.modules); + setProducts(payload.products || initialUniverseState.products); + setLedger(payload.ledger || initialUniverseState.ledger); + setSettings(payload.settings || initialUniverseState.settings); + setPersistedAt(payload.persistedAt || new Date().toISOString()); + }, []); + + const applySocialFeed = useCallback((payload: SocialFeed) => { + setSocialFeed(payload || initialSocialFeed); + }, []); + + const loadUniverseState = useCallback(async () => { + setIsLoadingUniverse(true); + setUniverseError(null); + setUniverseNotice(null); + + try { + const response = await axios.get('/vorta-universe/state'); + applyUniverseState(response.data); + setOtpPhone(response.data.profile?.phone || ''); + } catch (error) { + console.error('Failed to load VORTA Universe state:', error); + setUniverseError('Data VORTA Universe belum bisa dimuat dari database. Fallback demo lokal tetap ditampilkan.'); + } finally { + setIsLoadingUniverse(false); + } + }, [applyUniverseState]); + + const loadSocialFeed = useCallback(async () => { + setIsLoadingSocial(true); + setSocialError(null); + setSocialNotice(null); + + try { + const response = await axios.get('/vorta-universe/social-feed'); + applySocialFeed(response.data); + } catch (error) { + console.error('Failed to load VORTA social feed:', error); + setSocialError('Feed komunitas belum bisa dimuat dari tabel PostgreSQL.'); + } finally { + setIsLoadingSocial(false); + } + }, [applySocialFeed]); + + useEffect(() => { + loadUniverseState(); + loadSocialFeed(); + }, [loadUniverseState, loadSocialFeed]); + + const runUniverseAction = useCallback(async (action: UniverseAction, errorMessage: string) => { + setIsSavingUniverse(true); + setUniverseError(null); + setUniverseNotice(null); + + try { + const response = await action(); + applyUniverseState(response.data); + return true; + } catch (error) { + console.error(errorMessage, error); + setUniverseError(`${errorMessage} Cek backend log untuk detail.`); + return false; + } finally { + setIsSavingUniverse(false); + } + }, [applyUniverseState]); + + const runSocialAction = useCallback(async (action: SocialAction, errorMessage: string) => { + setIsSavingSocial(true); + setSocialError(null); + setSocialNotice(null); + + try { + const response = await action(); + applySocialFeed(response.data.feed); + applyUniverseState(response.data.state); + return true; + } catch (error) { + console.error(errorMessage, error); + setSocialError(`${errorMessage} Cek backend log untuk detail.`); + return false; + } finally { + setIsSavingSocial(false); + } + }, [applySocialFeed, applyUniverseState]); + + const metrics = useMemo(() => { + const inventoryValue = products.reduce((total, product) => total + product.price * product.stock, 0); + const activeModules = modules.filter((item) => item.status !== 'Offline').length; + const totalLaunches = modules.reduce((total, item) => total + (item.launches || 0), 0); + + return { + inventoryValue, + activeModules, + totalLaunches, + productCount: products.length, + totalStock: products.reduce((total, product) => total + product.stock, 0), + }; + }, [modules, products]); + + const greetingName = currentUser?.firstName || currentUser?.email || 'Founder VORTA'; + const trustLabel = getTrustLabel(profile.trustScore); + + const updateTrustScore = (nextScore: number) => { + runUniverseAction( + () => axios.patch('/vorta-universe/trust-score', { data: { score: nextScore } }), + 'Gagal memperbarui Nexus Trust Score.', + ); + }; + + const runModule = async (moduleId: string) => { + const target = modules.find((item) => item.id === moduleId); + const saved = await runUniverseAction( + () => axios.post(`/vorta-universe/modules/${moduleId}/run`), + 'Gagal menjalankan module VORTA Universe.', + ); + + if (saved) { + setUniverseNotice(`${target?.name || 'Module'} berhasil dijalankan dan tersimpan di ledger.`); + } + }; + + const runAllModules = async () => { + if (!modules.length) { + setUniverseError('Belum ada module untuk dijalankan.'); + return; + } + + setIsSavingUniverse(true); + setUniverseError(null); + setUniverseNotice(null); + + try { + let latestState: UniverseState | null = null; + + for (const item of modules) { + const response = await axios.post(`/vorta-universe/modules/${item.id}/run`); + latestState = response.data; + } + + if (latestState) { + applyUniverseState(latestState); + } + + setUniverseNotice(`Semua ${modules.length} module berhasil dijalankan berurutan.`); + } catch (error) { + console.error('Failed to run all VORTA modules:', error); + setUniverseError('Gagal menjalankan semua module VORTA Universe. Cek backend log untuk detail.'); + } finally { + setIsSavingUniverse(false); + } + }; + + const createDemoProduct = async () => { + const saved = await runUniverseAction( + () => axios.post('/vorta-universe/products/demo'), + 'Gagal menambahkan produk demo.', + ); + + if (saved) { + setUniverseNotice('Produk demo berhasil ditambahkan ke Marketplace Products.'); + } + }; + + const createCustomProduct = async () => { + if (!productDraft.name.trim()) { + setUniverseError('Nama produk custom wajib diisi.'); + return; + } + + const saved = await runUniverseAction( + () => axios.post('/vorta-universe/products', { + data: { + name: productDraft.name, + seller: productDraft.seller, + category: productDraft.category, + price: Number(productDraft.price), + stock: Number(productDraft.stock), + }, + }), + 'Gagal menambahkan produk custom.', + ); + + if (saved) { + setUniverseNotice(`${productDraft.name} berhasil ditambahkan ke marketplace.`); + setProductDraft(initialProductDraft); + } + }; + + const createPost = async () => { + if (!postContent.trim()) { + setSocialError('Isi post terlebih dahulu.'); + return; + } + + const saved = await runSocialAction( + () => axios.post('/vorta-universe/posts', { data: { content: postContent } }), + 'Gagal membuat post komunitas.', + ); + + if (saved) { + setPostContent(''); + setSocialNotice('Post komunitas berhasil tersimpan permanen di PostgreSQL.'); + } + }; + + const createComment = async (postId: string) => { + const comment = commentDrafts[postId] || ''; + + if (!comment.trim()) { + setSocialError('Isi komentar terlebih dahulu.'); + return; + } + + const saved = await runSocialAction( + () => axios.post(`/vorta-universe/posts/${postId}/comments`, { + data: { comment, parent_id: '0' }, + }), + 'Gagal membuat komentar komunitas.', + ); + + if (saved) { + setCommentDrafts((drafts) => ({ ...drafts, [postId]: '' })); + setSocialNotice('Komentar berhasil tersimpan permanen di PostgreSQL.'); + } + }; + + const requestOtp = async () => { + setIsOtpBusy(true); + setOtpStatus(null); + setOtpDevCode(null); + + try { + const response = await axios.post('/vorta-universe/auth/otp/request', { + data: { phone: otpPhone }, + }); + applyUniverseState(response.data.state); + setOtpStatus(response.data.message); + setOtpDevCode(response.data.devOtp); + } catch (error) { + console.error('Failed to request VORTA OTP:', error); + setOtpStatus('Gagal membuat OTP. Pastikan nomor HP minimal 8 digit.'); + } finally { + setIsOtpBusy(false); + } + }; + + const verifyOtp = async () => { + setIsOtpBusy(true); + setOtpStatus(null); + + try { + const response = await axios.post('/vorta-universe/auth/otp/verify', { + data: { phone: otpPhone, otp: otpCode }, + }); + applyUniverseState(response.data.state); + setOtpStatus(response.data.message); + setOtpDevCode(null); + setOtpCode(''); + } catch (error) { + console.error('Failed to verify VORTA OTP:', error); + setOtpStatus('OTP tidak valid atau kedaluwarsa. Request kode baru jika perlu.'); + } finally { + setIsOtpBusy(false); + } + }; + + const openGoogleLogin = () => { + window.location.assign('/api/auth/signin/google?app=/vorta-universe'); + }; + + const scrollToSection = (sectionId: string) => { + document.getElementById(sectionId)?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + }; + + const refreshEverything = async () => { + await Promise.all([loadUniverseState(), loadSocialFeed()]); + setUniverseNotice('State Universe dan social feed berhasil direfresh dari PostgreSQL.'); + setSocialNotice('Social feed berhasil direfresh dari PostgreSQL.'); + }; + + const resetUniverseState = async () => { + const saved = await runUniverseAction( + () => axios.post('/vorta-universe/state/reset'), + 'Gagal reset state VORTA Universe.', + ); + + if (saved) { + setProductDraft(initialProductDraft); + setOtpCode(''); + setOtpDevCode(null); + setOtpStatus('State demo berhasil di-reset. Request OTP baru jika ingin verifikasi ulang.'); + setUniverseNotice('State VORTA Universe berhasil di-reset ke data demo awal.'); + } + }; + + const resetSocialFeed = async () => { + const saved = await runSocialAction( + () => axios.post('/vorta-universe/social-feed/reset'), + 'Gagal reset social feed VORTA Universe.', + ); + + if (saved) { + setPostContent(''); + setCommentDrafts({}); + setSocialNotice('Social feed berhasil di-reset ke post dan komentar demo awal.'); + } + }; + + const clearSocialDrafts = () => { + setPostContent(''); + setCommentDrafts({}); + setSocialNotice('Draft post dan komentar di layar sudah dibersihkan.'); + }; + + return ( + <> + + {getPageTitle('VORTA Universe')} + + + + + + + {universeError ? ( +
+ {universeError} +
+ ) : null} + + {universeNotice ? ( +
+ {universeNotice} +
+ ) : null} + + {(isLoadingUniverse || isSavingUniverse) ? ( +
+ {isLoadingUniverse ? 'Mengambil state dari PostgreSQL...' : 'Menyimpan perubahan VORTA Universe...'} +
+ ) : null} + + +
+
+
+ Sync Persisten ยท Flatlogic Native ยท PostgreSQL +
+

+ ๐ŸŒ VORTA UNIVERSE +

+

+ Halo {greetingName}, prototype Express/SQLite Anda sekarang berjalan sebagai modul app utama: + auth bawaan, API existing, Sequelize, dan database PostgreSQL. +

+
+ updateTrustScore(profile.trustScore + 5)} + /> + updateTrustScore(profile.trustScore - 5)} + /> + runAllModules()} + /> + refreshEverything()} + /> + resetUniverseState()} + /> +
+
+
+

Nexus Trust Score

+
+ {profile.trustScore} + + {trustLabel} + +
+
+
+
+

+ Phone: {profile.phoneVerified ? `โœ… ${profile.phone}` : 'Belum verifikasi OTP'} +

+
+
+ + + +
+
+

Quick Menu VORTA

+

+ Menu kiri prototype Express/SQLite Anda dibuat aktif sebagai anchor dan shortcut app utama. +

+
+
+ + + refreshEverything()} + /> +
+
+
+ {quickMenuItems.map((item) => ( + + ))} +
+
+ +
+ +
+ +
+

Wallet Balance

+

{formatCurrency(profile.walletBalance)}

+
+
+
+ +
+ +
+

GeoSeek Radius

+

{profile.geoseekRadiusKm} km

+
+
+
+ +
+ +
+

VORTA AI Quota

+

{profile.aiQuota} prompt

+
+
+
+ +
+ +
+

Marketplace Value

+

{formatCurrency(metrics.inventoryValue)}

+
+
+
+
+ +
+ +

Masuk / Daftar

+

+ Panel ini mengambil ide dari prototype. Google dan Email/Password memakai auth bawaan Flatlogic. +

+
+ + + +
+
+

Status user saat ini

+

+ {currentUser?.email || 'Belum ada user terdeteksi'} ยท Role/auth tetap dari sistem Flatlogic. +

+
+
+ + +

๐Ÿ“ฑ OTP Login MVP

+

+ OTP development disimpan sebagai hash di PostgreSQL dan kode test ditampilkan di UI. +

+
+ setOtpPhone(event.target.value)} + /> +
+ setOtpCode(event.target.value)} + /> + verifyOtp()} + /> +
+ requestOtp()} + /> + {otpDevCode ? ( +
+ Kode OTP dev: {otpDevCode} +
+ ) : null} + {otpStatus ? ( +

{otpStatus}

+ ) : null} +
+
+
+ +
+ +
+
+

Universe Modules

+

+ {metrics.activeModules} module aktif, {formatNumber(metrics.totalLaunches)} launch tersimpan. +

+
+
+ runAllModules()} + /> + +
+
+
+ {modules.map((item) => ( +
+
+
{item.icon}
+ + {item.status} + +
+

{item.name}

+

{item.description}

+
+
+

Metric

+

{item.metric}

+
+
+ runModule(item.id)} + /> + +
+
+
+ ))} +
+
+ + +

Activity Ledger

+

+ Log aksi terbaru dari API VORTA Universe. +

+
+ {ledger.map((item) => ( +
+ +
+

{item.title}

+

{item.detail}

+

{item.time}

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

Community Posts & Comments

+

+ {socialFeed.stats.postsCount} post, {socialFeed.stats.commentsCount} komentar dari PostgreSQL. +

+
+
+ loadSocialFeed()} + /> + clearSocialDrafts()} + /> + resetSocialFeed()} + /> +
+
+ {socialError ? ( +
+ {socialError} +
+ ) : null} + {socialNotice ? ( +
+ {socialNotice} +
+ ) : null} +
+