Skip to main content

Backend Function (II): Post-validation - Data Masking and Processing

The previous article covered pre-validation functions - validation before data writes. This article covers post-validation functions - processing data before it's returned to the frontend.

Sensitive information like phone numbers and ID cards should not be exposed directly to the frontend; some fields need dynamic calculation (such as calculating age from birthday); date formats need to be standardized. All of these can be handled centrally in post-validation functions, so the data the frontend receives is already processed.

What You'll Learn
  • What is a post-validation function (After script)
  • How to implement data masking
  • Dynamically calculating field values
  • Auto-populating related data

Requirements

Automatically complete the following before data is returned to the frontend:

  • Sensitive data masking (hiding middle digits of phone numbers, ID numbers)
  • Dynamically calculated fields (calculating age from birthday, level from spending amount)
  • Data format conversion (date formatting)
  • Related data enrichment (automatically querying and populating related table information)
Database query results

Post-validation function (data processing)

Return to frontend (masked, calculated, formatted)

Core Concepts

Post-validation functions, also called After scripts or HOOK post-scripts, are automatically triggered after data operations execute and before returning to the frontend. You can modify the returned data structure here.

FeatureDescription
Trigger timingAfter operations like filter, getOne, create, update, etc.
Execution locationBackend (Lovrabet server environment)
Function namingafterFilter, afterCreate, afterGetOne, etc.

Unlike processing data on the frontend, After scripts execute uniformly on the backend, so the frontend doesn't need to worry about masking and formatting logic.


Implementation Steps

Step 1: Create Post-Validation Function on the Platform

Create a HOOK script on the Lovrabet platform:

File naming: customers_afterFilter.js (named by table name_operation type)

Platform configuration URL: https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list

Description: On the dataset's API list page, configure this script for the After phase of the filter operation.

/**
* Customer data post-validation function - After script
* Automatically masks and formats customer data before returning
*
* [API Path] POST /api/{appCode}/customers/filter
* Rule: /api/{appCode}/{datasetCode}/filter
*
* [Platform Configuration] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
* Description: appCode is the application code, datasetId is the dataset ID (integer, obtained from MCP get_dataset_detail)
*
* [HTTP Request Body Parameters]
* { "where": {...}, "select": [...] }
*
* [Return Data Structure]
* HOOK: Returns modified params object (containing tableData, tableColumns)
*
* @param {Object} params - filter return result
* @param {Array} params.tableData - data list
* @param {Object} params.tableColumns - column definitions
* @param {Object} context - execution context (automatically injected by platform, no need to pass when calling)
* @param {Object} context.userInfo - current user information
* @returns {Object} Returns modified params
*/
export default async function afterFilter(params, context) {
// Dataset code mapping table
const TABLES = {
customers: "dataset_XXXXXXXXXX", // Dataset: customers | Table: customers (replace with actual 32-digit code)
};
const models = context.client.models;

console.log("[Post-validation] Starting data processing");

// Process list data (After script operates on params.tableData)
if (params.tableData) {
params.tableData = params.tableData.map((item) =>
processCustomer(item, context)
);
}

return params;
}

/**
* Process a single customer record
*/
function processCustomer(customer, context) {
if (!customer) return customer;

// ========== 1. Sensitive data masking ==========
if (customer.phone) {
customer.phone = maskPhone(customer.phone);
customer.phoneOriginal = undefined; // Ensure original data is not returned
}

if (customer.idCard) {
customer.idCard = maskIdCard(customer.idCard);
}

if (customer.bankAccount) {
customer.bankAccount = maskBankAccount(customer.bankAccount);
}

// ========== 2. Dynamically calculated fields ==========
// Calculate customer age
if (customer.birthday) {
customer.age = calculateAge(customer.birthday);
}

// Calculate customer level (based on spending amount)
if (customer.totalAmount !== undefined) {
customer.level = calculateLevel(customer.totalAmount);
}

// ========== 3. Format conversion ==========
// Date formatting
if (customer.createTime) {
customer.createTimeFormatted = formatDate(customer.createTime);
}

if (customer.lastLoginTime) {
customer.lastLoginTimeFormatted = formatDate(customer.lastLoginTime);
}

// ========== 4. Status display name ==========
if (customer.status) {
customer.statusName = getStatusName(customer.status);
}

// ========== 5. Permission control: Hide internal fields ==========
const user = context.userInfo;
if (user?.role !== "admin") {
delete customer.internalNotes;
delete customer.creditScore;
}

return customer;
}

// ========== Utility functions ==========

/**
* Phone number masking: 138****8000
*/
function maskPhone(phone) {
if (!phone || phone.length < 11) return phone;
return phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
}

/**
* ID card masking: 320***********1234
*/
function maskIdCard(idCard) {
if (!idCard || idCard.length < 18) return idCard;
return idCard.replace(/^(.{3}).*(.{4})$/, "$1***********$2");
}

/**
* Bank account masking: 6222***********1234
*/
function maskBankAccount(account) {
if (!account || account.length < 16) return account;
return account.replace(/^(.{4}).*(.{4})$/, "$1***********$2");
}

/**
* Calculate age
*/
function calculateAge(birthdayStr) {
const today = new Date();
const birthDate = new Date(birthdayStr);
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();

if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < birthDate.getDate())
) {
age--;
}

return age;
}

/**
* Calculate customer level based on spending amount
*/
function calculateLevel(totalAmount) {
if (totalAmount >= 50000) return "Diamond Member";
if (totalAmount >= 20000) return "Gold Member";
if (totalAmount >= 5000) return "Silver Member";
return "Regular Member";
}

/**
* Format date
*/
function formatDate(dateStr) {
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");

return `${year}-${month}-${day} ${hours}:${minutes}`;
}

/**
* Get status display name
*/
function getStatusName(status) {
const names = {
potential: "Potential Customer",
dealed: "Closed",
lost: "Lost",
active: "Active",
inactive: "Inactive",
};
return names[status] || status;
}

Step 2: Configure Post-Validation Function Binding

Configure the After script on the Lovrabet platform:

Platform configuration URL: https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list

Post-validation Configuration

  1. Navigate to the dataset's API list page
  2. Select the operation to configure (e.g., filter query operation)
  3. In the "Business Logic Extension" section, find the "Post-validation Function" card
  4. Click the "Edit" button to open the script editor
  5. Write the After script code and save

Notes:

  • Each operation (filter/getOne/create/update) can have its own independently configured After script
  • After scripts trigger after the operation executes and before returning data, useful for data masking, format conversion, etc.
  • Status showing "None" means not configured; once configured, the function name will be displayed

Step 3: Frontend Receives Processed Data

No frontend code changes needed - the data received is already processed:

// Query list
const result = await lovrabetClient.models.dataset_customers.filter({
where: { status: { $eq: "active" } },
});

// Data is already masked and calculated
console.log(result.tableData[0]);
// Output:
// {
// id: 123,
// name: "John Doe",
// phone: "138****8000", // Already masked
// age: 28, // Already calculated
// level: "Gold Member", // Already calculated
// statusName: "Active", // Already formatted
// createTimeFormatted: "2024-01-15 10:30" // Already formatted
// }

Advanced Scenarios

/**
* Order query post-script - Auto-populate related data
* Automatically queries and populates customer info and order details when returning orders
*
* [API Path] POST /api/{appCode}/orders/filter
*
* [Platform Configuration] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
*
* @param {Object} params - filter return result
* @param {Array} params.tableData - data list
* @param {Object} context - execution context (automatically injected by platform)
* @returns {Object} Returns modified params
*/
export default async function afterFilter(params, context) {
// Dataset code mapping table
const TABLES = {
orders: "dataset_XXXXXXXXXX", // Dataset: orders | Table: orders
customers: "dataset_YYYYYYYYYY", // Dataset: customers | Table: customers
orderItems: "dataset_ZZZZZZZZZZ", // Dataset: order_items | Table: order_items
};
const models = context.client.models;

if (!params.tableData || params.tableData.length === 0) {
return params;
}

// Batch get customer IDs and order IDs
const customerIds = [
...new Set(params.tableData.map((o) => o.customerId).filter(Boolean)),
];
const orderIds = [
...new Set(params.tableData.map((o) => o.id).filter(Boolean)),
];

// Batch query customer data
let customerMap = new Map();
if (customerIds.length > 0) {
const customers = await models[TABLES.customers].filter({
where: { id: { $in: customerIds } },
});
customerMap = new Map(customers.tableData.map((c) => [c.id, c]));
}

// Batch query order items
let itemsMap = new Map();
if (orderIds.length > 0) {
const allItems = await models[TABLES.orderItems].filter({
where: { orderId: { $in: orderIds } },
});
// Group by order ID
allItems.tableData.forEach((item) => {
if (!itemsMap.has(item.orderId)) {
itemsMap.set(item.orderId, []);
}
itemsMap.get(item.orderId).push(item);
});
}

// Enrich order data
params.tableData.forEach((order) => {
// Populate customer info
const customer = customerMap.get(order.customerId);
if (customer) {
order.customerName = customer.name;
order.customerPhone = customer.phone; // Already masked
order.customerLevel = customer.level;
}

// Populate order items
order.items = itemsMap.get(order.id) || [];

// Calculate order status display
order.statusText = getOrderStatusText(order.status);
});

return params;
}

function getOrderStatusText(status) {
const texts = {
pending: "Pending Payment",
paid: "Paid",
shipped: "Shipped",
completed: "Completed",
cancelled: "Cancelled",
};
return texts[status] || status;
}

Multi-Language Data Conversion

/**
* Product query post-script - Return corresponding language based on user language preference
*
* [API Path] POST /api/{appCode}/products/filter
*
* [Platform Configuration] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
*
* @param {Object} params - filter return result
* @param {Array} params.tableData - data list
* @param {Object} context - execution context (automatically injected by platform)
* @returns {Object} Returns modified params
*/
export default async function afterFilter(params, context) {
// Get user language preference (default to Chinese)
const lang = context.userInfo?.language || "zh-CN";

const processItem = (item) => {
// Select field based on language
if (lang.startsWith("en")) {
item.displayName = item.nameEn || item.name;
item.description = item.descriptionEn || item.description;
} else if (lang.startsWith("zh")) {
item.displayName = item.nameZh || item.name;
item.description = item.descriptionZh || item.description;
} else {
item.displayName = item.name;
item.description = item.description;
}

// Format amount (based on region)
if (item.amount) {
if (lang === "en-US") {
item.amountFormatted = `$${item.amount.toLocaleString()}`;
} else {
item.amountFormatted = `¥${item.amount.toLocaleString()}`;
}
}

return item;
};

if (params.tableData) {
params.tableData = params.tableData.map(processItem);
}

return params;
}

Data Aggregation and Statistics

/**
* Order query post-script - Add aggregation statistics to list data
*
* [API Path] POST /api/{appCode}/orders/filter
*
* [Platform Configuration] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
*
* @param {Object} params - filter return result
* @param {Array} params.tableData - data list
* @param {Object} context - execution context (automatically injected by platform)
* @returns {Object} Returns modified params
*/
export default async function afterFilter(params, context) {
if (!params.tableData || params.tableData.length === 0) {
return params;
}

const items = params.tableData;

// Calculate statistics
const stats = {
total: items.length,
totalAmount: items.reduce((sum, item) => sum + (item.amount || 0), 0),
highValueCount: items.filter((item) => (item.amount || 0) > 10000).length,
statusDistribution: {},
};

// Status distribution
items.forEach((item) => {
const status = item.status || "unknown";
stats.statusDistribution[status] =
(stats.statusDistribution[status] || 0) + 1;
});

// Add statistics to result
params.statistics = stats;

return params;
}

Building Tree Structure Data

/**
* Department query post-script - Convert flat data to tree structure
* Suitable for hierarchical data like departments, categories, etc.
*
* [API Path] POST /api/{appCode}/departments/filter
*
* [Platform Configuration] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
*
* @param {Object} params - filter return result
* @param {Array} params.tableData - data list
* @param {Object} context - execution context (automatically injected by platform)
* @returns {Object} Returns modified params
*/
export default async function afterFilter(params, context) {
if (!params.tableData || params.tableData.length === 0) {
return params;
}

const items = params.tableData;

// Build tree structure
const buildTree = (parentId = null) => {
return items
.filter((item) => item.parentId === parentId)
.map((item) => ({
...item,
children: buildTree(item.id),
}));
};

const tree = buildTree(null);

// Return both flat data and tree data
params.treeData = tree;

return params;
}

Knowledge Points Summary

After Script Function Signature

/**
* After script example
*
* [API Path] POST /api/{appCode}/{datasetCode}/filter
*
* [Platform Configuration] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
*
* @param {Object} params - filter return result
* @param {Array} params.tableData - data list
* @param {Object} params.tableColumns - column definitions
* @param {Object} context - execution context (automatically injected by platform)
* @returns {Object} Returns modified params
*/
export default async function afterFilter(params, context) {
// Operate on params.tableData
if (params.tableData) {
params.tableData = params.tableData.map((item) => {
// Process data
return item;
});
}

return params;
}

params Parameter (After Phase)

FieldTypeDescription
tableDataArrayData list
tableColumnsObjectColumn definitions
pagingObjectPagination info (filter interface)

Data Processing Considerations

// Correct: Return processed params
export default async function afterFilter(params, context) {
if (params.tableData) {
params.tableData = params.tableData.map(item => {
item.processed = true;
return item;
});
}

return params; // Must return
}

// Wrong: Forgot to return data
export default async function afterFilter(params, context) {
// Process data...
// Forgot to return
}

Best Practices

Sensitive Data Masking

// Unified masking function
function maskSensitive(value, type) {
if (!value) return value;

const masks = {
phone: (v) => v.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2"),
email: (v) => v.replace(/(.{2}).*(@.*)/, "$1***$2"),
idcard: (v) => v.replace(/^(.{3}).*(.{4})$/, "$1***********$2"),
bankcard: (v) => v.replace(/^(.{4}).*(.{4})$/, "$1***********$2"),
};

return masks[type] ? masks[type](value) : value;
}

Permission Control

// Decide whether to show certain fields based on user role
function filterFieldsByRole(data, user) {
const sensitiveFields = ["internalNotes", "creditScore"];

if (user?.role !== "admin") {
sensitiveFields.forEach((field) => delete data[field]);
}

return data;
}

Batch Query Optimization

// For batch queries, use a single query to get all data
async function enrichWithCustomer(orders, context) {
const TABLES = {
customers: "dataset_XXXXXXXXXX", // Dataset: customers | Table: customers
};
const models = context.client.models;

// Collect all customer IDs
const customerIds = [
...new Set(orders.map((o) => o.customerId).filter(Boolean)),
];

// Query all customers at once
const customers = await models[TABLES.customers].filter({
where: { id: { $in: customerIds } },
});

// Build Map
const customerMap = new Map(customers.tableData.map((c) => [c.id, c]));

// Enrich order data
orders.forEach((order) => {
const customer = customerMap.get(order.customerId);
if (customer) {
order.customerName = customer.name;
}
});

return orders;
}

FAQ

Q: Will post-validation functions affect performance?

A: Yes, since they execute on every query. Recommendations:

  1. Avoid complex queries: Minimize database queries in post-validation functions
  2. Use batch queries: When processing lists, use filter to query all related data at once
  3. Add caching: Use caching for data that doesn't change frequently
// Not recommended: Query in loop
for (const order of orders) {
const customer = await models[TABLES.customers].findOne({
id: order.customerId,
});
order.customerName = customer.name;
}

// Recommended: Batch query
const customerIds = orders.map((o) => o.customerId);
const result = await models[TABLES.customers].filter({
where: { id: { $in: customerIds } },
});
const customerMap = new Map(result.tableData.map((c) => [c.id, c]));
orders.forEach((order) => {
order.customerName = customerMap.get(order.customerId)?.name;
});

Q: Can pre-validation and post-validation functions be used together?

A: Yes. They are independent and can be configured simultaneously:

FunctionExecution TimingPurpose
Pre-validationBefore operationValidation, permission control, data enrichment
Post-validationAfter operationMasking, format conversion, related data

Q: What if an error occurs in the post-validation function?

A: Errors in post-validation functions won't affect data operations, but will affect the returned result:

export default async function transform(params, context) {
try {
// Processing logic
return processResult(params.result);
} catch (error) {
console.error("[Post-validation] Processing failed:", error);
// Return original data to ensure frontend at least gets data
return params.result;
}
}

Q: How to check if data has been processed on the frontend?

A: You can add a marker field:

function processCustomer(customer) {
customer._processed = true; // Add processing marker
customer.phone = maskPhone(customer.phone);
return customer;
}

// Frontend check
if (customer._processed) {
console.log("Data has been masked");
}

Summary

Congratulations on learning post-validation functions! Key points:

Knowledge PointDescription
After scriptFunction that automatically executes after data operations
params.tableDataList data, can be modified directly
Data maskingProcessing sensitive information like phone numbers, ID cards
Dynamic calculationDerived fields like age, level
Best Practices
  • Sensitive data must be masked on the backend, cannot rely on frontend
  • Use batch queries to optimize related data retrieval
  • Add permission control to hide fields based on roles
  • Catch exceptions to ensure data can be returned normally

Next Steps

Core Documentation

Advanced Topics


Difficulty Level: L2 | Estimated Time: 40 minutes