Added excel support.
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
This commit is contained in:
parent
fcaa5a1043
commit
f852e607f9
@ -13,6 +13,7 @@
|
||||
"canonical-json": "^0.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"exceljs": "^4.4.0",
|
||||
"exifr": "^7.1.3",
|
||||
"express": "^5.1.0",
|
||||
"express-session": "^1.18.2",
|
||||
|
||||
463
pnpm-lock.yaml
generated
463
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
160
src/database/excel.js
Normal file
160
src/database/excel.js
Normal file
@ -0,0 +1,160 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import log4js from 'log4js';
|
||||
import ExcelJS from 'exceljs';
|
||||
import config from '../config.js';
|
||||
import { redisServer } from './redis.js';
|
||||
|
||||
const logger = log4js.getLogger('Excel');
|
||||
const EXCEL_TEMP_KEY_PREFIX = 'excel:temp:';
|
||||
const EXCEL_TEMP_TTL_SECONDS = 15; // 15 seconds
|
||||
|
||||
const excelNanoid = customAlphabet(
|
||||
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
|
||||
12
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a temp token and store export params in Redis.
|
||||
* @param {Object} params - { objectType, filter, sort, order }
|
||||
* @returns {Promise<{ token: string, url: string }>}
|
||||
*/
|
||||
export async function createExcelTempToken(params) {
|
||||
const baseUrl = config.app?.urlApi?.replace(/\/$/, '') || '';
|
||||
if (!baseUrl) {
|
||||
throw new Error('config.app.urlApi is not set; required for Excel temp URLs');
|
||||
}
|
||||
const objectType = params.objectType || 'Export';
|
||||
const now = new Date();
|
||||
const datetime =
|
||||
now.getFullYear() +
|
||||
String(now.getMonth() + 1).padStart(2, '0') +
|
||||
String(now.getDate()).padStart(2, '0') +
|
||||
String(now.getHours()).padStart(2, '0') +
|
||||
String(now.getMinutes()).padStart(2, '0');
|
||||
const token = `${objectType}s-${datetime}-${excelNanoid()}`;
|
||||
const key = EXCEL_TEMP_KEY_PREFIX + token;
|
||||
const stored = { ...params, requestCount: 0 };
|
||||
await redisServer.setKey(key, stored, EXCEL_TEMP_TTL_SECONDS);
|
||||
logger.debug('Stored excel temp token in Redis:', key);
|
||||
const url = `${baseUrl}/excel/temp/${token}.xlsx`;
|
||||
return { token, url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get export params for a temp token (supports up to 2 requests; requestCount stored in Redis).
|
||||
* @param {string} token
|
||||
* @returns {Promise<Object|null>} { objectType, filter, sort, order } or null
|
||||
*/
|
||||
export async function getExcelTempParams(token) {
|
||||
if (!token) return null;
|
||||
const key = EXCEL_TEMP_KEY_PREFIX + token;
|
||||
const params = await redisServer.getKey(key);
|
||||
if (!params) {
|
||||
logger.debug('Excel temp token not found in Redis:', key);
|
||||
return null;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value to an Excel cell-friendly format.
|
||||
* Primitives pass through; objects/arrays are stringified; dates are preserved.
|
||||
*/
|
||||
function toExcelValue(val) {
|
||||
if (val === null || val === undefined) return null;
|
||||
if (val instanceof Date) return val;
|
||||
if (typeof val === 'number' || typeof val === 'boolean') return val;
|
||||
if (typeof val === 'string') return val;
|
||||
if (typeof val === 'object') {
|
||||
if (Array.isArray(val)) return val.map(toExcelValue).join(', ');
|
||||
return JSON.stringify(val);
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an Excel workbook from tabular data.
|
||||
* @param {Array<Object>} data - Array of row objects (keys = column headers)
|
||||
* @param {Object} options - Options
|
||||
* @param {string} [options.sheetName='Export'] - Worksheet name
|
||||
* @param {string[]} [options.columnOrder] - Optional column order (uses Object.keys of first row if not provided)
|
||||
* @returns {Promise<Buffer>} Excel file as buffer
|
||||
*/
|
||||
export async function generateExcelTable(data, options = {}) {
|
||||
const { sheetName = 'Export', columnOrder } = options;
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet(sheetName, {
|
||||
views: [{ state: 'frozen', ySplit: 1 }],
|
||||
});
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
const keys = columnOrder || Object.keys(data[0]).filter((k) => !k.startsWith('@'));
|
||||
const colCount = keys.length;
|
||||
const rowCount = data.length + 1;
|
||||
|
||||
const toColLetter = (n) => {
|
||||
let s = '';
|
||||
while (n >= 0) {
|
||||
s = String.fromCharCode((n % 26) + 65) + s;
|
||||
n = Math.floor(n / 26) - 1;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const endCol = toColLetter(colCount - 1);
|
||||
|
||||
const tableRows = data.map((row) => keys.map((key) => toExcelValue(row[key])));
|
||||
worksheet.addTable({
|
||||
name: 'DataTable',
|
||||
ref: `A1:${endCol}${rowCount}`,
|
||||
headerRow: true,
|
||||
style: {
|
||||
theme: 'TableStyleLight1',
|
||||
showRowStripes: true,
|
||||
},
|
||||
columns: keys.map((key) => ({ name: key, filterButton: true })),
|
||||
rows: tableRows,
|
||||
});
|
||||
|
||||
// Auto-fit columns (approximate)
|
||||
worksheet.columns.forEach((col, i) => {
|
||||
let maxLen = keys[i]?.length || 10;
|
||||
worksheet.eachRow({ includeEmpty: false }, (row) => {
|
||||
const cell = row.getCell(i + 1);
|
||||
const val = cell.value;
|
||||
const len = val != null ? String(val).length : 0;
|
||||
maxLen = Math.min(Math.max(maxLen, len), 50);
|
||||
});
|
||||
col.width = maxLen + 2;
|
||||
});
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment request count for a temp token. Returns new count or null if token not found.
|
||||
* @param {string} token
|
||||
* @returns {Promise<number|null>} New requestCount or null
|
||||
*/
|
||||
export async function incrementExcelTempRequestCount(token) {
|
||||
if (!token) return null;
|
||||
const key = EXCEL_TEMP_KEY_PREFIX + token;
|
||||
const data = await redisServer.getKey(key);
|
||||
if (!data) return null;
|
||||
const requestCount = (data.requestCount ?? 0) + 1;
|
||||
const updated = { ...data, requestCount };
|
||||
await redisServer.setKey(key, updated, EXCEL_TEMP_TTL_SECONDS);
|
||||
logger.debug('Incremented excel temp request count:', key, requestCount);
|
||||
return requestCount;
|
||||
}
|
||||
|
||||
export async function deleteExcelTempToken(token) {
|
||||
const key = EXCEL_TEMP_KEY_PREFIX + token;
|
||||
await redisServer.deleteKey(key);
|
||||
logger.debug('Deleted excel temp token from Redis:', key);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import bodyParser from 'body-parser';
|
||||
import cors from 'cors';
|
||||
import config from './config.js';
|
||||
import { dbConnect } from './database/mongo.js';
|
||||
import { redisServer } from './database/redis.js';
|
||||
import {
|
||||
authRoutes,
|
||||
userRoutes,
|
||||
@ -44,6 +45,7 @@ import {
|
||||
userNotifierRoutes,
|
||||
notificationRoutes,
|
||||
odataRoutes,
|
||||
excelRoutes,
|
||||
} from './routes/index.js';
|
||||
import path from 'path';
|
||||
import * as fs from 'fs';
|
||||
@ -83,6 +85,9 @@ async function initializeApp() {
|
||||
// Connect to database
|
||||
await dbConnect();
|
||||
|
||||
// Connect to Redis (required for excel temp tokens, sessions, cache)
|
||||
await redisServer.connect();
|
||||
|
||||
// Connect to NATS
|
||||
await natsServer.connect();
|
||||
|
||||
@ -153,6 +158,7 @@ app.use('/notes', noteRoutes);
|
||||
app.use('/usernotifiers', userNotifierRoutes);
|
||||
app.use('/notifications', notificationRoutes);
|
||||
app.use('/odata', odataRoutes);
|
||||
app.use('/excel', excelRoutes);
|
||||
|
||||
// Start the application
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
|
||||
@ -38,6 +38,7 @@ import noteRoutes from './misc/notes.js';
|
||||
import userNotifierRoutes from './misc/usernotifiers.js';
|
||||
import notificationRoutes from './misc/notifications.js';
|
||||
import odataRoutes from './misc/odata.js';
|
||||
import excelRoutes from './misc/excel.js';
|
||||
|
||||
export {
|
||||
userRoutes,
|
||||
@ -80,4 +81,5 @@ export {
|
||||
userNotifierRoutes,
|
||||
notificationRoutes,
|
||||
odataRoutes,
|
||||
excelRoutes,
|
||||
};
|
||||
|
||||
16
src/routes/misc/excel.js
Normal file
16
src/routes/misc/excel.js
Normal file
@ -0,0 +1,16 @@
|
||||
import express from 'express';
|
||||
import { isAuthenticated } from '../../keycloak.js';
|
||||
import {
|
||||
excelExportRouteHandler,
|
||||
excelOpenRouteHandler,
|
||||
excelTempRouteHandler,
|
||||
} from '../../services/misc/excel.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Temp route must come before /:objectType so "temp" is not matched as objectType
|
||||
router.get('/temp/:token', excelTempRouteHandler);
|
||||
router.post('/:objectType/open', isAuthenticated, excelOpenRouteHandler);
|
||||
router.get('/:objectType', isAuthenticated, excelExportRouteHandler);
|
||||
|
||||
export default router;
|
||||
289
src/services/misc/excel.js
Normal file
289
src/services/misc/excel.js
Normal file
@ -0,0 +1,289 @@
|
||||
import config from '../../config.js';
|
||||
import log4js from 'log4js';
|
||||
import { getModelByName } from './model.js';
|
||||
import { listObjectsOData } from '../../database/odata.js';
|
||||
import { getFilter } from '../../utils.js';
|
||||
import {
|
||||
generateExcelTable,
|
||||
createExcelTempToken,
|
||||
getExcelTempParams,
|
||||
incrementExcelTempRequestCount,
|
||||
deleteExcelTempToken,
|
||||
} from '../../database/excel.js';
|
||||
|
||||
const logger = log4js.getLogger('Excel');
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
/**
|
||||
* Flatten nested objects for Excel display.
|
||||
* Objects become "key.subkey: value" or JSON string; arrays become comma-separated.
|
||||
*/
|
||||
function flattenForExcel(obj, prefix = '') {
|
||||
if (obj === null || obj === undefined) return {};
|
||||
if (typeof obj !== 'object') return { [prefix]: obj };
|
||||
if (obj instanceof Date) return { [prefix]: obj };
|
||||
if (Array.isArray(obj)) {
|
||||
const str = obj
|
||||
.map((v) => (v && typeof v === 'object' && !(v instanceof Date) ? JSON.stringify(v) : v))
|
||||
.join(', ');
|
||||
return { [prefix]: str };
|
||||
}
|
||||
const result = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v !== null && typeof v === 'object' && !(v instanceof Date) && !Array.isArray(v)) {
|
||||
Object.assign(result, flattenForExcel(v, key));
|
||||
} else {
|
||||
result[key] = v;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a row to flat key-value for Excel. Nested objects are flattened.
|
||||
*/
|
||||
function rowToFlat(row) {
|
||||
const flat = {};
|
||||
for (const [key, val] of Object.entries(row)) {
|
||||
if (key.startsWith('@')) continue;
|
||||
if (val !== null && typeof val === 'object' && !(val instanceof Date) && !Array.isArray(val)) {
|
||||
Object.assign(flat, flattenForExcel(val, key));
|
||||
} else {
|
||||
flat[key] = val;
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get allowed filter fields for Excel export (reuse OData logic).
|
||||
*/
|
||||
function getModelFilterFields(objectType) {
|
||||
const base = ['_id'];
|
||||
const byType = {
|
||||
note: ['parent._id', 'noteType', 'user'],
|
||||
notification: ['user'],
|
||||
userNotifier: ['user', 'object', 'objectType'],
|
||||
printer: ['host'],
|
||||
job: ['printer', 'gcodeFile'],
|
||||
subJob: ['job'],
|
||||
filamentStock: ['filament'],
|
||||
partStock: ['part'],
|
||||
purchaseOrder: ['vendor'],
|
||||
orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'shipment._id'],
|
||||
shipment: ['order._id', 'orderType', 'courierService._id'],
|
||||
stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
|
||||
stockAudit: ['filamentStock._id', 'partStock._id'],
|
||||
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'],
|
||||
documentTemplate: ['parent._id', 'documentSize._id'],
|
||||
salesOrder: ['client'],
|
||||
invoice: ['to._id', 'from._id', 'order._id', 'orderType'],
|
||||
auditLog: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
|
||||
appPassword: ['name', 'user', 'active'],
|
||||
};
|
||||
const extra = byType[objectType] || [];
|
||||
return [...base, ...extra];
|
||||
}
|
||||
|
||||
function parseOrderBy(orderby) {
|
||||
if (!orderby || typeof orderby !== 'string') {
|
||||
return { sort: 'createdAt', order: 'ascend' };
|
||||
}
|
||||
const trimmed = orderby.trim();
|
||||
const parts = trimmed.split(/\s+/);
|
||||
const sort = parts[0] || 'createdAt';
|
||||
const dir = (parts[1] || 'asc').toLowerCase();
|
||||
const order = dir === 'desc' ? 'descend' : 'ascend';
|
||||
return { sort, order };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Excel file for the given object type.
|
||||
* @param {Object} options
|
||||
* @param {string} options.objectType - Model type (e.g. 'appPassword', 'user')
|
||||
* @param {Object} [options.filter] - Filter object
|
||||
* @param {string} [options.sort] - Sort field
|
||||
* @param {string} [options.order] - 'ascend' | 'descend'
|
||||
* @param {number} [options.limit=10000] - Max rows to export
|
||||
* @returns {Promise<{ buffer: Buffer, error?: Object }>}
|
||||
*/
|
||||
export async function exportToExcel({ objectType, filter = {}, sort, order, limit = 10000 }) {
|
||||
logger.info('[Excel Export] Starting', { objectType, filter, sort, order });
|
||||
const entry = getModelByName(objectType);
|
||||
if (!entry?.model) {
|
||||
logger.warn('[Excel Export] Unknown object type:', objectType);
|
||||
return { error: { message: `Unknown object type: ${objectType}` }, code: 404 };
|
||||
}
|
||||
|
||||
const orderbyStr = sort ? `${sort} ${order === 'descend' ? 'desc' : 'asc'}` : undefined;
|
||||
const { sort: sortField, order: orderDir } = parseOrderBy(orderbyStr);
|
||||
|
||||
const result = await listObjectsOData({
|
||||
model: entry.model,
|
||||
populate: [],
|
||||
page: 1,
|
||||
limit,
|
||||
filter,
|
||||
sort: sortField,
|
||||
order: orderDir,
|
||||
pagination: true,
|
||||
project: undefined,
|
||||
count: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
logger.error('[Excel Export] listObjectsOData error:', result.error);
|
||||
return { error: result.error, code: result.code || 500 };
|
||||
}
|
||||
|
||||
const rows = result?.value || [];
|
||||
logger.info('[Excel Export] Rows fetched', { rowCount: rows.length });
|
||||
const flatRows = rows.map(rowToFlat);
|
||||
|
||||
const allKeys = new Set();
|
||||
flatRows.forEach((r) => Object.keys(r).forEach((k) => allKeys.add(k)));
|
||||
const columnOrder = Array.from(allKeys).sort();
|
||||
|
||||
let buffer;
|
||||
try {
|
||||
buffer = await generateExcelTable(flatRows, {
|
||||
sheetName: entry.model.modelName || objectType,
|
||||
columnOrder,
|
||||
});
|
||||
logger.info('[Excel Export] Buffer generated', { bufferLength: buffer?.length });
|
||||
} catch (err) {
|
||||
logger.error('[Excel Export] generateExcelTable threw:', err?.message, err?.stack);
|
||||
return { error: { message: err.message || 'Failed to generate Excel' }, code: 500 };
|
||||
}
|
||||
|
||||
return { buffer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Route handler for GET /odata/:objectType/excel
|
||||
*/
|
||||
export const excelExportRouteHandler = async (req, res) => {
|
||||
const objectType = req.params.objectType;
|
||||
const allowedFilters = getModelFilterFields(objectType);
|
||||
const filter = getFilter(req.query, allowedFilters);
|
||||
const { sort, order } = parseOrderBy(req.query.$orderby);
|
||||
|
||||
const result = await exportToExcel({
|
||||
objectType,
|
||||
filter,
|
||||
sort,
|
||||
order,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return res.status(result.code || 500).json(result.error);
|
||||
}
|
||||
|
||||
const filename = `${objectType}-export-${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
res.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.set('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(result.buffer);
|
||||
};
|
||||
|
||||
/**
|
||||
* Route handler for POST /excel/:objectType/open - creates a temporary URL for opening in Excel.
|
||||
* Stores export params in Redis; spreadsheet is generated when Excel fetches the temp URL.
|
||||
* Returns { url } - an https URL Excel can fetch (no auth required, one-time use, 5 min expiry).
|
||||
*/
|
||||
export const excelOpenRouteHandler = async (req, res) => {
|
||||
try {
|
||||
const objectType = req.params.objectType;
|
||||
logger.info('[Excel Open] POST received', { objectType, query: req.query });
|
||||
const allowedFilters = getModelFilterFields(objectType);
|
||||
const filter = getFilter(req.query, allowedFilters);
|
||||
const { sort, order } = parseOrderBy(req.query.$orderby);
|
||||
|
||||
const entry = getModelByName(objectType);
|
||||
if (!entry?.model) {
|
||||
logger.warn('[Excel Open] Unknown object type:', objectType);
|
||||
return res.status(404).json({ message: `Unknown object type: ${objectType}` });
|
||||
}
|
||||
|
||||
const params = { objectType, filter, sort, order };
|
||||
logger.info('[Excel Open] Creating temp token with params:', JSON.stringify(params));
|
||||
const { token, url } = await createExcelTempToken(params);
|
||||
if (!url) {
|
||||
logger.error('[Excel Open] createExcelTempToken returned no url');
|
||||
return res.status(500).json({ message: 'Failed to create temporary URL' });
|
||||
}
|
||||
logger.info('[Excel Open] Temp URL created', { objectType, token, url });
|
||||
res.json({ url });
|
||||
} catch (err) {
|
||||
logger.error('[Excel Open] Route error:', err?.message, err?.stack);
|
||||
res.status(500).json({ message: err.message || 'Failed to create Excel URL' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Route handler for GET /excel/temp/:token - generates and serves temporary Excel file (no auth).
|
||||
* Retrieves export params from Redis, generates spreadsheet on demand.
|
||||
* Token allows 2 requests before deletion; expires after 5 minutes.
|
||||
*/
|
||||
export const excelTempRouteHandler = async (req, res) => {
|
||||
const tokenParam = req.params.token || '';
|
||||
const token = tokenParam.endsWith('.xlsx') ? tokenParam.slice(0, -5) : tokenParam;
|
||||
logger.info('[Excel Temp] GET received', {
|
||||
tokenParam,
|
||||
token,
|
||||
userAgent: req.get('User-Agent'),
|
||||
referer: req.get('Referer'),
|
||||
});
|
||||
|
||||
const params = await getExcelTempParams(token);
|
||||
if (!params) {
|
||||
logger.error('[Excel Temp] Token not found or expired', { token, tokenParam });
|
||||
return res.status(404).json({ message: 'Link expired or invalid' });
|
||||
}
|
||||
|
||||
logger.info('[Excel Temp] Params retrieved from Redis', { objectType: params.objectType });
|
||||
|
||||
const result = await exportToExcel({
|
||||
objectType: params.objectType,
|
||||
filter: params.filter || {},
|
||||
sort: params.sort,
|
||||
order: params.order,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
logger.error('[Excel Temp] Export failed', { error: result.error, code: result.code });
|
||||
return res.status(result.code || 500).json(result.error);
|
||||
}
|
||||
|
||||
const buffer = Buffer.isBuffer(result.buffer) ? result.buffer : Buffer.from(result.buffer);
|
||||
const filename = `${token}.xlsx`;
|
||||
logger.info('[Excel Temp] Sending buffer', { bufferLength: buffer.length });
|
||||
|
||||
// Strong ETag based on buffer length + timestamp
|
||||
const strongETag = `"${buffer.length.toString(16)}-${Date.now()}"`;
|
||||
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('ETag', strongETag);
|
||||
|
||||
// Remove headers that may break Excel
|
||||
res.removeHeader('Vary');
|
||||
res.removeHeader('Access-Control-Allow-Credentials');
|
||||
res.removeHeader('X-Powered-By');
|
||||
|
||||
res.end(buffer);
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const requestCount = await incrementExcelTempRequestCount(token);
|
||||
if (requestCount !== null && requestCount >= 2) {
|
||||
await deleteExcelTempToken(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user