Auto commit: 2026-04-11T08:13:09.677Z

This commit is contained in:
Flatlogic Bot 2026-04-11 08:13:09 +00:00
parent dfebf532cf
commit 8e4bc70d03
12 changed files with 3319 additions and 401 deletions

View File

@ -0,0 +1,189 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const rows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.marketing_campaigns') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
if (rows[0].regclass_name) {
await transaction.commit();
return;
}
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "enum_marketing_campaigns_status";',
{ transaction },
);
await queryInterface.createTable(
'marketing_campaigns',
{
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
campaignName: {
type: Sequelize.DataTypes.TEXT,
allowNull: false,
},
campaignType: {
type: Sequelize.DataTypes.TEXT,
allowNull: false,
defaultValue: 'تسجيل الطلاب',
},
objective: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
audience: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
gradeFocus: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
platform: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
tone: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
offer: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
callToAction: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
publishingDate: {
type: Sequelize.DataTypes.DATE,
allowNull: true,
},
notes: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
generatedContent: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
weeklyPlan: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
designerNotes: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
videoScript: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
messageTemplate: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
assetNames: {
type: Sequelize.DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
status: {
type: Sequelize.DataTypes.ENUM,
values: ['draft', 'ready', 'scheduled', 'published', 'archived'],
allowNull: false,
defaultValue: 'draft',
},
isAutoGenerated: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
lastGeneratedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: true,
},
createdAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
deletedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: true,
},
createdById: {
type: Sequelize.DataTypes.UUID,
allowNull: true,
references: {
key: 'id',
model: 'users',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
updatedById: {
type: Sequelize.DataTypes.UUID,
allowNull: true,
references: {
key: 'id',
model: 'users',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
},
{ transaction },
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const rows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.marketing_campaigns') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
if (!rows[0].regclass_name) {
await transaction.commit();
return;
}
await queryInterface.dropTable('marketing_campaigns', { transaction });
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "enum_marketing_campaigns_status";',
{ transaction },
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,145 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const rows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.marketing_schedules') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
if (rows[0].regclass_name) {
await transaction.commit();
return;
}
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "enum_marketing_schedules_status";',
{ transaction },
);
await queryInterface.createTable(
'marketing_schedules',
{
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
title: {
type: Sequelize.DataTypes.TEXT,
allowNull: false,
},
channel: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
publishAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
type: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
status: {
type: Sequelize.DataTypes.ENUM,
values: ['draft', 'scheduled', 'published', 'cancelled'],
allowNull: false,
defaultValue: 'scheduled',
},
notes: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
payload: {
type: Sequelize.DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
campaignId: {
type: Sequelize.DataTypes.UUID,
allowNull: true,
references: {
key: 'id',
model: 'marketing_campaigns',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
createdAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
deletedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: true,
},
createdById: {
type: Sequelize.DataTypes.UUID,
allowNull: true,
references: {
key: 'id',
model: 'users',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
updatedById: {
type: Sequelize.DataTypes.UUID,
allowNull: true,
references: {
key: 'id',
model: 'users',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
},
{ transaction },
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const rows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.marketing_schedules') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
if (!rows[0].regclass_name) {
await transaction.commit();
return;
}
await queryInterface.dropTable('marketing_schedules', { transaction });
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "enum_marketing_schedules_status";',
{ transaction },
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,148 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const rows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.marketing_referrals') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
if (rows[0].regclass_name) {
await transaction.commit();
return;
}
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "enum_marketing_referrals_status";',
{ transaction },
);
await queryInterface.createTable(
'marketing_referrals',
{
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
referrerName: {
type: Sequelize.DataTypes.TEXT,
allowNull: false,
},
referrerPhone: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
studentName: {
type: Sequelize.DataTypes.TEXT,
allowNull: false,
},
studentGrade: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
discountAmount: {
type: Sequelize.DataTypes.DECIMAL(10, 2),
allowNull: true,
},
rewardNotes: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
status: {
type: Sequelize.DataTypes.ENUM,
values: ['new', 'qualified', 'rewarded', 'cancelled'],
allowNull: false,
defaultValue: 'new',
},
notes: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
inquiryId: {
type: Sequelize.DataTypes.UUID,
allowNull: true,
references: {
key: 'id',
model: 'student_inquiries',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
createdAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
deletedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: true,
},
createdById: {
type: Sequelize.DataTypes.UUID,
allowNull: true,
references: {
key: 'id',
model: 'users',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
updatedById: {
type: Sequelize.DataTypes.UUID,
allowNull: true,
references: {
key: 'id',
model: 'users',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
},
{ transaction },
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const rows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.marketing_referrals') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
if (!rows[0].regclass_name) {
await transaction.commit();
return;
}
await queryInterface.dropTable('marketing_referrals', { transaction });
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "enum_marketing_referrals_status";',
{ transaction },
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,120 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const rows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.marketing_templates') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
if (rows[0].regclass_name) {
await transaction.commit();
return;
}
await queryInterface.createTable(
'marketing_templates',
{
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: Sequelize.DataTypes.TEXT,
allowNull: false,
},
category: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
channel: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
content: {
type: Sequelize.DataTypes.TEXT,
allowNull: false,
},
previewText: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
isDefault: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
createdAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
deletedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: true,
},
createdById: {
type: Sequelize.DataTypes.UUID,
allowNull: true,
references: {
key: 'id',
model: 'users',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
updatedById: {
type: Sequelize.DataTypes.UUID,
allowNull: true,
references: {
key: 'id',
model: 'users',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
},
{ transaction },
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const rows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.marketing_templates') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
if (!rows[0].regclass_name) {
await transaction.commit();
return;
}
await queryInterface.dropTable('marketing_templates', { transaction });
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,119 @@
module.exports = (sequelize, DataTypes) => {
const marketing_campaigns = sequelize.define(
'marketing_campaigns',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
campaignName: {
type: DataTypes.TEXT,
allowNull: false,
},
campaignType: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'تسجيل الطلاب',
},
objective: {
type: DataTypes.TEXT,
allowNull: true,
},
audience: {
type: DataTypes.TEXT,
allowNull: true,
},
gradeFocus: {
type: DataTypes.TEXT,
allowNull: true,
},
platform: {
type: DataTypes.TEXT,
allowNull: true,
},
tone: {
type: DataTypes.TEXT,
allowNull: true,
},
offer: {
type: DataTypes.TEXT,
allowNull: true,
},
callToAction: {
type: DataTypes.TEXT,
allowNull: true,
},
publishingDate: {
type: DataTypes.DATE,
allowNull: true,
},
notes: {
type: DataTypes.TEXT,
allowNull: true,
},
generatedContent: {
type: DataTypes.TEXT,
allowNull: true,
},
weeklyPlan: {
type: DataTypes.TEXT,
allowNull: true,
},
designerNotes: {
type: DataTypes.TEXT,
allowNull: true,
},
videoScript: {
type: DataTypes.TEXT,
allowNull: true,
},
messageTemplate: {
type: DataTypes.TEXT,
allowNull: true,
},
assetNames: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
status: {
type: DataTypes.ENUM,
values: ['draft', 'ready', 'scheduled', 'published', 'archived'],
allowNull: false,
defaultValue: 'draft',
},
isAutoGenerated: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
lastGeneratedAt: {
type: DataTypes.DATE,
allowNull: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
marketing_campaigns.associate = (db) => {
db.marketing_campaigns.hasMany(db.marketing_schedules, {
as: 'schedules',
foreignKey: 'campaignId',
});
db.marketing_campaigns.belongsTo(db.users, {
as: 'createdBy',
});
db.marketing_campaigns.belongsTo(db.users, {
as: 'updatedBy',
});
};
return marketing_campaigns;
};

View File

@ -0,0 +1,68 @@
module.exports = (sequelize, DataTypes) => {
const marketing_referrals = sequelize.define(
'marketing_referrals',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
referrerName: {
type: DataTypes.TEXT,
allowNull: false,
},
referrerPhone: {
type: DataTypes.TEXT,
allowNull: true,
},
studentName: {
type: DataTypes.TEXT,
allowNull: false,
},
studentGrade: {
type: DataTypes.TEXT,
allowNull: true,
},
discountAmount: {
type: DataTypes.DECIMAL(10, 2),
allowNull: true,
},
rewardNotes: {
type: DataTypes.TEXT,
allowNull: true,
},
status: {
type: DataTypes.ENUM,
values: ['new', 'qualified', 'rewarded', 'cancelled'],
allowNull: false,
defaultValue: 'new',
},
notes: {
type: DataTypes.TEXT,
allowNull: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
marketing_referrals.associate = (db) => {
db.marketing_referrals.belongsTo(db.student_inquiries, {
as: 'inquiry',
foreignKey: 'inquiryId',
});
db.marketing_referrals.belongsTo(db.users, {
as: 'createdBy',
});
db.marketing_referrals.belongsTo(db.users, {
as: 'updatedBy',
});
};
return marketing_referrals;
};

View File

@ -0,0 +1,65 @@
module.exports = (sequelize, DataTypes) => {
const marketing_schedules = sequelize.define(
'marketing_schedules',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
title: {
type: DataTypes.TEXT,
allowNull: false,
},
channel: {
type: DataTypes.TEXT,
allowNull: true,
},
publishAt: {
type: DataTypes.DATE,
allowNull: false,
},
type: {
type: DataTypes.TEXT,
allowNull: true,
},
status: {
type: DataTypes.ENUM,
values: ['draft', 'scheduled', 'published', 'cancelled'],
allowNull: false,
defaultValue: 'scheduled',
},
notes: {
type: DataTypes.TEXT,
allowNull: true,
},
payload: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: {},
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
marketing_schedules.associate = (db) => {
db.marketing_schedules.belongsTo(db.marketing_campaigns, {
as: 'campaign',
foreignKey: 'campaignId',
});
db.marketing_schedules.belongsTo(db.users, {
as: 'createdBy',
});
db.marketing_schedules.belongsTo(db.users, {
as: 'updatedBy',
});
};
return marketing_schedules;
};

View File

@ -0,0 +1,54 @@
module.exports = (sequelize, DataTypes) => {
const marketing_templates = sequelize.define(
'marketing_templates',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: DataTypes.TEXT,
allowNull: false,
},
category: {
type: DataTypes.TEXT,
allowNull: true,
},
channel: {
type: DataTypes.TEXT,
allowNull: true,
},
content: {
type: DataTypes.TEXT,
allowNull: false,
},
previewText: {
type: DataTypes.TEXT,
allowNull: true,
},
isDefault: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
marketing_templates.associate = (db) => {
db.marketing_templates.belongsTo(db.users, {
as: 'createdBy',
});
db.marketing_templates.belongsTo(db.users, {
as: 'updatedBy',
});
};
return marketing_templates;
};

View File

@ -38,6 +38,7 @@ const app_settingsRoutes = require('./routes/app_settings');
const api_keysRoutes = require('./routes/api_keys');
const studentInquiriesRoutes = require('./routes/student_inquiries');
const schoolMarketingRoutes = require('./routes/school_marketing');
const getBaseUrl = (url) => {
@ -115,6 +116,7 @@ app.use('/api/app_settings', passport.authenticate('jwt', {session: false}), app
app.use('/api/api_keys', passport.authenticate('jwt', {session: false}), api_keysRoutes);
app.use('/api/student-inquiries', studentInquiriesRoutes);
app.use('/api/school-marketing', schoolMarketingRoutes);
app.use(
'/api/openai',

View File

@ -0,0 +1,609 @@
const express = require('express');
const passport = require('passport');
const { Op } = require('sequelize');
const db = require('../db/models');
const Helpers = require('../helpers');
const router = express.Router();
const wrapAsync = Helpers.wrapAsync;
const DEFAULT_TEMPLATES = [
{
name: 'ترحيب واتساب لأولياء الأمور',
category: 'واتساب',
channel: 'واتساب',
content: 'مرحبًا بكم في {{schoolName}}. يسعدنا استقبالكم وتعريفكم ببرامجنا التعليمية. هل يناسبكم موعد زيارة هذا الأسبوع؟',
previewText: 'رسالة أول تواصل سريعة ومحترفة.',
isDefault: true,
},
{
name: 'إعلان التسجيل المبكر',
category: 'إعلان',
channel: 'إنستغرام',
content: 'ابدأ مستقبل طفلك مع {{schoolName}}. التسجيل المبكر متاح الآن مع مزايا خاصة ومقاعد محدودة.',
previewText: 'نص إعلان قصير للحملات المدفوعة.',
isDefault: true,
},
{
name: 'سكربت فيديو تعريفي',
category: 'فيديو',
channel: 'فيديو',
content: 'المشهد 1: لقطات للمدرسة. المشهد 2: الأنشطة. المشهد 3: رسالة ثقة للأهالي. المشهد 4: دعوة لحجز زيارة.',
previewText: 'هيكل أولي لفيديو قصير.',
isDefault: true,
},
];
const CAMPAIGN_STATUSES = ['draft', 'ready', 'scheduled', 'published', 'archived'];
const SCHEDULE_STATUSES = ['draft', 'scheduled', 'published', 'cancelled'];
const REFERRAL_STATUSES = ['new', 'qualified', 'rewarded', 'cancelled'];
function makeBadRequest(message) {
const error = new Error(message);
error.code = 400;
return error;
}
function makeNotFound(message) {
const error = new Error(message);
error.code = 404;
return error;
}
function cleanText(value) {
if (typeof value !== 'string') {
return '';
}
return value.trim();
}
function normalizeNullableText(value) {
const cleaned = cleanText(value);
return cleaned || null;
}
function normalizeDate(value, fieldName, required = false) {
const cleaned = cleanText(value);
if (!cleaned) {
if (required) {
throw makeBadRequest(`${fieldName} is required`);
}
return null;
}
const parsed = new Date(cleaned);
if (Number.isNaN(parsed.getTime())) {
throw makeBadRequest(`${fieldName} is invalid`);
}
return parsed;
}
function normalizeAssetNames(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => cleanText(item))
.filter(Boolean)
.slice(0, 50);
}
function buildCampaignPayload(data = {}) {
const campaignName = cleanText(data.campaignName);
if (!campaignName) {
throw makeBadRequest('Campaign name is required');
}
const status = cleanText(data.status) || 'draft';
if (!CAMPAIGN_STATUSES.includes(status)) {
throw makeBadRequest('Campaign status is invalid');
}
return {
campaignName,
campaignType: cleanText(data.campaignType) || 'تسجيل الطلاب',
objective: normalizeNullableText(data.objective),
audience: normalizeNullableText(data.audience),
gradeFocus: normalizeNullableText(data.gradeFocus),
platform: normalizeNullableText(data.platform),
tone: normalizeNullableText(data.tone),
offer: normalizeNullableText(data.offer),
callToAction: normalizeNullableText(data.callToAction),
publishingDate: normalizeDate(data.publishingDate, 'Publishing date'),
notes: normalizeNullableText(data.notes),
generatedContent: normalizeNullableText(data.generatedContent),
weeklyPlan: normalizeNullableText(data.weeklyPlan),
designerNotes: normalizeNullableText(data.designerNotes),
videoScript: normalizeNullableText(data.videoScript),
messageTemplate: normalizeNullableText(data.messageTemplate),
assetNames: normalizeAssetNames(data.assetNames),
status,
isAutoGenerated: Boolean(data.isAutoGenerated),
lastGeneratedAt: data.generatedContent || data.weeklyPlan || data.videoScript ? new Date() : null,
};
}
function buildSchedulePayload(data = {}) {
const title = cleanText(data.title);
if (!title) {
throw makeBadRequest('Schedule title is required');
}
const status = cleanText(data.status) || 'scheduled';
if (!SCHEDULE_STATUSES.includes(status)) {
throw makeBadRequest('Schedule status is invalid');
}
return {
title,
channel: normalizeNullableText(data.channel),
publishAt: normalizeDate(data.publishAt, 'Publish date', true),
type: normalizeNullableText(data.type),
status,
notes: normalizeNullableText(data.notes),
payload: typeof data.payload === 'object' && data.payload !== null ? data.payload : {},
campaignId: cleanText(data.campaignId) || null,
};
}
function buildTemplatePayload(data = {}) {
const name = cleanText(data.name);
const content = cleanText(data.content);
if (!name) {
throw makeBadRequest('Template name is required');
}
if (!content) {
throw makeBadRequest('Template content is required');
}
return {
name,
category: normalizeNullableText(data.category),
channel: normalizeNullableText(data.channel),
content,
previewText: normalizeNullableText(data.previewText),
isDefault: Boolean(data.isDefault),
};
}
function buildReferralPayload(data = {}) {
const referrerName = cleanText(data.referrerName);
const studentName = cleanText(data.studentName);
const status = cleanText(data.status) || 'new';
if (!referrerName) {
throw makeBadRequest('Referrer name is required');
}
if (!studentName) {
throw makeBadRequest('Student name is required');
}
if (!REFERRAL_STATUSES.includes(status)) {
throw makeBadRequest('Referral status is invalid');
}
return {
referrerName,
referrerPhone: normalizeNullableText(data.referrerPhone),
studentName,
studentGrade: normalizeNullableText(data.studentGrade),
discountAmount: data.discountAmount !== undefined && data.discountAmount !== null && `${data.discountAmount}` !== ''
? Number(data.discountAmount)
: null,
rewardNotes: normalizeNullableText(data.rewardNotes),
status,
notes: normalizeNullableText(data.notes),
inquiryId: cleanText(data.inquiryId) || null,
};
}
async function ensureDefaultTemplates(currentUserId) {
const count = await db.marketing_templates.count();
if (count > 0) {
return;
}
const now = new Date();
await db.marketing_templates.bulkCreate(
DEFAULT_TEMPLATES.map((item, index) => ({
...item,
createdById: currentUserId || null,
updatedById: currentUserId || null,
createdAt: new Date(now.getTime() + index * 1000),
updatedAt: new Date(now.getTime() + index * 1000),
})),
);
}
router.use(passport.authenticate('jwt', { session: false }));
router.get(
'/workspace',
wrapAsync(async (req, res) => {
await ensureDefaultTemplates(req.currentUser?.id || null);
const [
summary,
inquiries,
campaigns,
schedules,
referrals,
templates,
latestDraft,
] = await Promise.all([
db.student_inquiries.count().then(async (total) => ({
total,
newLeads: await db.student_inquiries.count({ where: { status: 'new' } }),
scheduled: await db.student_inquiries.count({ where: { status: 'demo_scheduled' } }),
enrolled: await db.student_inquiries.count({ where: { status: 'enrolled' } }),
referrals: await db.marketing_referrals.count(),
campaigns: await db.marketing_campaigns.count(),
scheduledPosts: await db.marketing_schedules.count({ where: { status: 'scheduled' } }),
})),
db.student_inquiries.findAll({ order: [['createdAt', 'DESC']], limit: 100 }),
db.marketing_campaigns.findAll({ order: [['updatedAt', 'DESC']], limit: 20 }),
db.marketing_schedules.findAll({ order: [['publishAt', 'ASC']], limit: 20 }),
db.marketing_referrals.findAll({ order: [['createdAt', 'DESC']], limit: 20 }),
db.marketing_templates.findAll({ order: [['isDefault', 'DESC'], ['updatedAt', 'DESC']], limit: 20 }),
db.marketing_campaigns.findOne({
where: { status: 'draft' },
order: [['updatedAt', 'DESC']],
}),
]);
res.status(200).send({
summary,
inquiries,
campaigns,
schedules,
referrals,
templates,
latestDraft,
});
}),
);
router.get(
'/campaigns',
wrapAsync(async (req, res) => {
const status = cleanText(req.query.status);
const q = cleanText(req.query.q);
const where = {};
if (status) {
if (!CAMPAIGN_STATUSES.includes(status)) {
throw makeBadRequest('Campaign status filter is invalid');
}
where.status = status;
}
if (q) {
where[Op.or] = [
{ campaignName: { [Op.iLike]: `%${q}%` } },
{ campaignType: { [Op.iLike]: `%${q}%` } },
{ platform: { [Op.iLike]: `%${q}%` } },
];
}
const rows = await db.marketing_campaigns.findAll({
where,
order: [['updatedAt', 'DESC']],
limit: 50,
});
res.status(200).send({ rows, count: rows.length });
}),
);
router.post(
'/campaigns',
wrapAsync(async (req, res) => {
const payload = buildCampaignPayload(req.body.data || {});
const campaign = await db.marketing_campaigns.create({
...payload,
createdById: req.currentUser?.id || null,
updatedById: req.currentUser?.id || null,
});
res.status(200).send(campaign);
}),
);
router.post(
'/campaigns/save-draft',
wrapAsync(async (req, res) => {
const payload = buildCampaignPayload({ ...(req.body.data || {}), status: 'draft' });
const campaignId = cleanText(req.body.data?.id);
let campaign;
if (campaignId) {
campaign = await db.marketing_campaigns.findByPk(campaignId);
if (!campaign) {
throw makeNotFound('Campaign not found');
}
await campaign.update({
...payload,
updatedById: req.currentUser?.id || null,
});
} else {
campaign = await db.marketing_campaigns.create({
...payload,
createdById: req.currentUser?.id || null,
updatedById: req.currentUser?.id || null,
});
}
res.status(200).send(campaign);
}),
);
router.put(
'/campaigns/:id',
wrapAsync(async (req, res) => {
const campaign = await db.marketing_campaigns.findByPk(req.params.id);
if (!campaign) {
throw makeNotFound('Campaign not found');
}
const payload = buildCampaignPayload(req.body.data || {});
await campaign.update({
...payload,
updatedById: req.currentUser?.id || null,
});
res.status(200).send(campaign);
}),
);
router.delete(
'/campaigns/:id',
wrapAsync(async (req, res) => {
const campaign = await db.marketing_campaigns.findByPk(req.params.id);
if (!campaign) {
throw makeNotFound('Campaign not found');
}
const relatedSchedules = await db.marketing_schedules.findAll({
where: { campaignId: campaign.id },
});
await Promise.all(relatedSchedules.map(async (schedule) => {
await schedule.update({ updatedById: req.currentUser?.id || null });
await schedule.destroy();
}));
await campaign.update({ updatedById: req.currentUser?.id || null });
await campaign.destroy();
res.status(200).send(true);
}),
);
router.get(
'/schedules',
wrapAsync(async (req, res) => {
const rows = await db.marketing_schedules.findAll({
order: [['publishAt', 'ASC']],
limit: 50,
});
res.status(200).send({ rows, count: rows.length });
}),
);
router.post(
'/schedules',
wrapAsync(async (req, res) => {
const payload = buildSchedulePayload(req.body.data || {});
const schedule = await db.marketing_schedules.create({
...payload,
createdById: req.currentUser?.id || null,
updatedById: req.currentUser?.id || null,
});
if (payload.campaignId) {
const campaign = await db.marketing_campaigns.findByPk(payload.campaignId);
if (campaign) {
await campaign.update({
status: 'scheduled',
publishingDate: payload.publishAt,
updatedById: req.currentUser?.id || null,
});
}
}
res.status(200).send(schedule);
}),
);
router.put(
'/schedules/:id',
wrapAsync(async (req, res) => {
const schedule = await db.marketing_schedules.findByPk(req.params.id);
if (!schedule) {
throw makeNotFound('Schedule not found');
}
const payload = buildSchedulePayload(req.body.data || {});
await schedule.update({
...payload,
updatedById: req.currentUser?.id || null,
});
if (payload.campaignId) {
const campaign = await db.marketing_campaigns.findByPk(payload.campaignId);
if (campaign) {
let nextStatus = 'scheduled';
if (payload.status === 'published') {
nextStatus = 'published';
}
if (payload.status === 'cancelled') {
nextStatus = 'ready';
}
await campaign.update({
status: nextStatus,
publishingDate: payload.publishAt,
updatedById: req.currentUser?.id || null,
});
}
}
res.status(200).send(schedule);
}),
);
router.delete(
'/schedules/:id',
wrapAsync(async (req, res) => {
const schedule = await db.marketing_schedules.findByPk(req.params.id);
if (!schedule) {
throw makeNotFound('Schedule not found');
}
await schedule.update({ updatedById: req.currentUser?.id || null });
await schedule.destroy();
res.status(200).send(true);
}),
);
router.get(
'/templates',
wrapAsync(async (req, res) => {
await ensureDefaultTemplates(req.currentUser?.id || null);
const rows = await db.marketing_templates.findAll({
order: [['isDefault', 'DESC'], ['updatedAt', 'DESC']],
limit: 50,
});
res.status(200).send({ rows, count: rows.length });
}),
);
router.post(
'/templates',
wrapAsync(async (req, res) => {
const payload = buildTemplatePayload(req.body.data || {});
const template = await db.marketing_templates.create({
...payload,
createdById: req.currentUser?.id || null,
updatedById: req.currentUser?.id || null,
});
res.status(200).send(template);
}),
);
router.put(
'/templates/:id',
wrapAsync(async (req, res) => {
const template = await db.marketing_templates.findByPk(req.params.id);
if (!template) {
throw makeNotFound('Template not found');
}
const payload = buildTemplatePayload(req.body.data || {});
await template.update({
...payload,
updatedById: req.currentUser?.id || null,
});
res.status(200).send(template);
}),
);
router.delete(
'/templates/:id',
wrapAsync(async (req, res) => {
const template = await db.marketing_templates.findByPk(req.params.id);
if (!template) {
throw makeNotFound('Template not found');
}
await template.update({ updatedById: req.currentUser?.id || null });
await template.destroy();
res.status(200).send(true);
}),
);
router.get(
'/referrals',
wrapAsync(async (req, res) => {
const rows = await db.marketing_referrals.findAll({
include: [
{
model: db.student_inquiries,
as: 'inquiry',
required: false,
},
],
order: [['createdAt', 'DESC']],
limit: 50,
});
res.status(200).send({ rows, count: rows.length });
}),
);
router.post(
'/referrals',
wrapAsync(async (req, res) => {
const payload = buildReferralPayload(req.body.data || {});
const referral = await db.marketing_referrals.create({
...payload,
createdById: req.currentUser?.id || null,
updatedById: req.currentUser?.id || null,
});
res.status(200).send(referral);
}),
);
router.put(
'/referrals/:id',
wrapAsync(async (req, res) => {
const referral = await db.marketing_referrals.findByPk(req.params.id);
if (!referral) {
throw makeNotFound('Referral not found');
}
const payload = buildReferralPayload(req.body.data || {});
await referral.update({
...payload,
updatedById: req.currentUser?.id || null,
});
res.status(200).send(referral);
}),
);
router.delete(
'/referrals/:id',
wrapAsync(async (req, res) => {
const referral = await db.marketing_referrals.findByPk(req.params.id);
if (!referral) {
throw makeNotFound('Referral not found');
}
await referral.update({ updatedById: req.currentUser?.id || null });
await referral.destroy();
res.status(200).send(true);
}),
);
router.use('/', Helpers.commonErrorHandler);
module.exports = router;

View File

@ -5,12 +5,12 @@ const menuAside: MenuAsideItem[] = [
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
label: 'لوحة التحكم',
},
{
href: '/school-marketing',
label: 'التسويق المدرسي',
label: 'استوديو التسويق المدرسي',
icon: icon.mdiBullhornOutline,
},

File diff suppressed because it is too large Load Diff