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) => (
scrollToSection(sectionId)}
+ onClick={() => handleSectionNavigation(item.id, item.label)}
className='rounded-full px-4 py-2 transition hover:bg-white/10 hover:text-white focus:outline-none focus:ring-2 focus:ring-cyan-300/50'
>
- {label}
+ {item.label}
))}
@@ -311,18 +432,14 @@ export default function VortaLanding() {
- {[
- ['pillars', 'Super-App'],
- ['trust-flow', 'Skor'],
- ['reports', 'Laporan'],
- ].map(([sectionId, label]) => (
+ {sectionLinks.map((item) => (
scrollToSection(sectionId)}
+ onClick={() => handleSectionNavigation(item.id, item.label)}
className='rounded-full border border-white/10 bg-white/[0.08] px-4 py-2 transition hover:bg-white/15 focus:outline-none focus:ring-2 focus:ring-cyan-300/50'
>
- {label}
+ {item.label}
))}
@@ -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) => (
-
(
+
handleWorkspaceModule(module)}
+ className={`rounded-2xl border p-4 text-left transition hover:-translate-y-0.5 hover:border-cyan-200/40 hover:bg-white/15 focus:outline-none focus:ring-4 focus:ring-cyan-300/30 ${
+ activeWorkspaceModule === module.title
+ ? 'border-cyan-200/60 bg-cyan-200/15'
+ : 'border-white/10 bg-white/[0.08]'
+ }`}
>
- {item}
-
+
{module.title}
+
+ {module.description}
+
+
+ {module.metric}
+
+
))}
- Aktivitas terpadu hari ini
+ Modul aktif: {activeWorkspaceModule}
128
-
- Transaksi & pesan sinkron
-
+
+ handleSectionNavigation(
+ 'trust-flow',
+ 'Simulator Skor',
+ )
+ }
+ className='rounded-full bg-white px-3 py-1 text-sm font-bold text-slate-950 transition hover:bg-cyan-100 focus:outline-none focus:ring-4 focus:ring-cyan-300/30'
+ >
+ Jalankan sinkronisasi
+
@@ -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)}
+ className='block w-full text-left focus:outline-none focus:ring-4 focus:ring-cyan-300/30'
+ >
+
+ 0{index + 1}
+
+ {pillar.title}
+
+ {pillar.description}
+
+
+
+
+ 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) => (
+
scrollToSection(item.id)}
+ >
+
+
+
+
+
+ {item.label}
+ {item.description}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
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}
+
+
+ {!socialFeed.posts.length ? (
+
+ Belum ada post. Klik Refresh Feed untuk membuat seed demo awal atau tulis post pertama.
+
+ ) : null}
+ {socialFeed.posts.map((post) => (
+
+
+
{post.user}
+
{formatDateTime(post.created_at)}
+
+
{post.content}
+
+ {post.comments.map((comment) => (
+
+
+
{comment.user}
+
+ {formatDateTime(comment.created_at)} ยท parent {comment.parent_id}
+
+
+
{comment.comment}
+
+ ))}
+
+
+ setCommentDrafts((drafts) => ({
+ ...drafts,
+ [post.id]: event.target.value,
+ }))}
+ />
+ createComment(post.id)}
+ />
+
+
+ ))}
+
+
+
+
+ SQLite โ PostgreSQL
+
+ SQL prototype Anda diterjemahkan aman tanpa bentrok dengan entity posts existing.
+
+
+
+
Posts table
+
vorta_social_posts
+
user, content, created_at
+
+
+
Comments table
+
vorta_social_comments
+
+ post_id, user, comment, parent_id, created_at
+
+
+
+
OTP table
+
vorta_login_otps
+
Kode OTP disimpan hash bcrypt.
+
+
+
+
+
+
+
+
+
+
Marketplace Products
+
+ {metrics.productCount} produk, total stok {formatNumber(metrics.totalStock)} unit.
+
+
+
createDemoProduct()}
+ />
+
+
+
+
+
+
+
+ Produk
+ Seller
+ Kategori
+ Harga
+ Stok
+
+
+
+ {products.map((product) => (
+
+
+ {product.name}
+ {product.id}
+
+ {product.seller}
+
+
+ {product.category}
+
+
+ {formatCurrency(product.price)}
+ {formatNumber(product.stock)}
+
+ ))}
+
+
+
+
+
+
+ Universe Settings
+ Konfigurasi aktif dari state backend.
+
+ refreshEverything()}
+ />
+ resetUniverseState()}
+ />
+
+
+
+
Business
+
{profile.businessName}
+
+
+
Marketplace Mode
+
{settings.marketplaceMode}
+
+
+
Risk Review
+
{settings.riskReview}
+
+
+
Persisted At
+
{persistedAt || 'Menunggu sync database'}
+
+
+
+
+
+ >
+ );
+};
+
+VortaUniversePage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
+
+export default VortaUniversePage;