// ============== KITA INTERVIEW DATABASE + CV WORKFLOW ==============
// Apps Script Web App v4.0 - Lưu hồ sơ PV + Upload CV + Lead review + Send/Submit test
// Tác giả: KITA Interview System
const SHEET_NAME = 'PhongVan';
const CV_SHEET_NAME = 'CVQueue';
const CONFIG_SHEET = 'Config'; // Sheet lưu cấu hình động (anh edit qua /quan-tri/)
const API_KEY = 'KITA-MyTeam-2026'; // ⚠️ Phải khớp với cloudApiKey trong /config.js
// 🎯 CÁC CẤU HÌNH KHÁC (Drive folder, Lark webhook, Lark emails, etc.)
// → Đều LẤY TỪ SHEET "Config" qua hàm getConfig('key')
// → Anh chỉ cần edit ở /quan-tri/ → KHÔNG cần deploy lại Apps Script
// ============== READ CONFIG FROM SHEET (cached 60s) ==============
let _configCache = null;
let _configCacheTime = 0;
function getConfig(key) {
if (!_configCache || Date.now() - _configCacheTime > 60000) {
refreshConfigCache();
}
return _configCache[key] || '';
}
function refreshConfigCache() {
try {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG_SHEET);
_configCache = {};
if (sheet) {
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][0]) _configCache[String(data[i][0]).trim()] = String(data[i][1] || '').trim();
}
}
_configCacheTime = Date.now();
} catch(e) {
_configCache = {};
_configCacheTime = Date.now();
}
}
// ============== POST: Lưu hồ sơ HOẶC phân tích CV bằng Claude ==============
function doPost(e) {
try {
const data = JSON.parse(e.postData.contents);
if (data.apiKey !== API_KEY) {
return jsonResponse({ ok: false, error: 'Sai API key' });
}
// Action: cập nhật config động (Lark, Drive folder, etc.)
if (data.action === 'update_config') {
return updateConfig(data);
}
// Action: test gửi Lark (Apps Script proxy — vì browser bị CORS)
if (data.action === 'test_lark') {
return testLarkWebhook(data);
}
// Action: upload CV file mới
if (data.action === 'upload_cv') {
return uploadCV(data);
}
// Action: Lead review CV (approve/reject/potential)
if (data.action === 'review_cv') {
return reviewCV(data);
}
// Action: Gửi bài test (sau PV V1)
if (data.action === 'send_test') {
return sendTest(data);
}
// Action: Ứng viên nộp bài test (PUBLIC - không cần API key check ở đây vì token là auth)
if (data.action === 'submit_test') {
return submitTest(data);
}
// Action mặc định: lưu hồ sơ vào sheet
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
if (!sheet) {
return jsonResponse({ ok: false, error: 'Không tìm thấy sheet "' + SHEET_NAME + '"' });
}
const scores = Object.values(data.scores || {}).filter(s => typeof s === 'number');
const avg = scores.length ? (scores.reduce((a,b) => a+b, 0) / scores.length).toFixed(2) : '';
let rec = '';
if (avg >= 4) rec = '✅ PASS';
else if (avg >= 3) rec = '🟡 CÂN NHẮC';
else if (scores.length > 0) rec = '❌ LOẠI';
const candName = (data.candidate || {}).name || '';
const exportType = data.exportType || 'all';
const existingRow = findExistingRow(sheet, candName, exportType);
const rowData = [
new Date(),
candName,
(data.candidate || {}).position || '',
(data.candidate || {}).date || '',
(data.candidate || {}).interviewer || '',
exportType,
(data.candidate || {}).contact || '',
(data.candidate || {}).source || '',
JSON.stringify(data.scores || {}),
JSON.stringify(data.notes || {}),
JSON.stringify(data.qa || []),
JSON.stringify(data.decision || {}),
JSON.stringify(data.hrDecision || {}),
avg,
rec,
JSON.stringify(data)
];
if (existingRow > 0) {
sheet.getRange(existingRow, 1, 1, rowData.length).setValues([rowData]);
return jsonResponse({ ok: true, action: 'update', row: existingRow });
} else {
sheet.appendRow(rowData);
return jsonResponse({ ok: true, action: 'insert', row: sheet.getLastRow() });
}
} catch (err) {
return jsonResponse({ ok: false, error: err.message });
}
}
// ============== UPLOAD CV (HR upload file → Drive + CVQueue sheet) ==============
function uploadCV(data) {
const folderId = getConfig('cv_drive_folder_id');
if (!folderId) {
return jsonResponse({ ok: false, error: 'CV Drive folder ID chưa cấu hình. Vào /quan-tri/ để set.' });
}
try {
const folder = DriveApp.getFolderById(folderId);
const cand = data.candidate || {};
// Decode base64 file → save lên Drive
const bytes = Utilities.base64Decode(data.fileBase64);
const blob = Utilities.newBlob(bytes, data.fileMimeType, data.fileName);
const safeName = (cand.name || 'unknown').replace(/[^\w\s-]/g, '').replace(/\s+/g, '_');
const finalName = safeName + '_' + new Date().toISOString().slice(0,10) + '_' + data.fileName;
const file = folder.createFile(blob).setName(finalName);
// Try set sharing public (có thể fail do Workspace policy → bỏ qua, file vẫn truy cập được khi folder đã share)
try {
file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
} catch(shareErr) {
Logger.log('setSharing skipped: ' + shareErr.message);
}
const fileUrl = file.getUrl();
// Upload Portfolio file (optional)
let portfolioFileUrl = '';
if (data.portfolioFileBase64 && data.portfolioFileName) {
try {
const pBytes = Utilities.base64Decode(data.portfolioFileBase64);
const pBlob = Utilities.newBlob(pBytes, data.portfolioFileMime || 'application/octet-stream', data.portfolioFileName);
const pFinalName = 'Portfolio_' + safeName + '_' + new Date().toISOString().slice(0,10) + '_' + data.portfolioFileName;
const pFile = folder.createFile(pBlob).setName(pFinalName);
try { pFile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); } catch(e) {}
portfolioFileUrl = pFile.getUrl();
} catch (pErr) {
Logger.log('Portfolio upload failed: ' + pErr.message);
}
}
// Append vào CVQueue sheet
let sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CV_SHEET_NAME);
if (!sheet) {
sheet = SpreadsheetApp.getActiveSpreadsheet().insertSheet(CV_SHEET_NAME);
sheet.appendRow(['Timestamp','Tên','SĐT/Email','Vị trí','Nguồn','CV file','HR upload','Note HR','Status','Lead reviewer','Note Lead','Lịch PV','Reviewed at','HR Score','HR Score Detail']);
}
// Đảm bảo có cột HR Score + HR Score Detail + Portfolio
ensureHrScoreColumns(sheet);
ensurePortfolioColumns(sheet);
// Build row theo header để đảm bảo đúng vị trí
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const rowData = new Array(headers.length).fill('');
const setCol = (h, val) => {
const idx = headers.indexOf(h);
if (idx >= 0) rowData[idx] = val;
};
setCol('Timestamp', new Date());
setCol('Tên', cand.name || '');
setCol('SĐT/Email', cand.contact || '');
setCol('Vị trí', cand.position || '');
setCol('Nguồn', cand.source || '');
setCol('CV file', fileUrl);
setCol('HR upload', cand.hrUploader || 'HR');
setCol('Note HR', cand.hrNote || '');
setCol('Status', 'PENDING');
setCol('HR Score', cand.hrScore || '');
setCol('HR Score Detail', JSON.stringify(cand.hrScoreDetail || {}));
setCol('Portfolio link', cand.portfolio || '');
setCol('Portfolio file', portfolioFileUrl);
sheet.appendRow(rowData);
invalidateCVCache();
// 🔔 Lark notify: HR vừa upload CV mới
notifyLark('NEW_CV', { candidate: cand, note: cand.hrNote });
return jsonResponse({ ok: true, row: sheet.getLastRow(), fileUrl: fileUrl, portfolioFileUrl: portfolioFileUrl });
} catch (err) {
return jsonResponse({ ok: false, error: err.message });
}
}
// ============== LIST CV QUEUE (cache 60s qua CacheService) ==============
function listCVQueue() {
try {
const cache = CacheService.getScriptCache();
const cached = cache.get('cv_queue_v1');
if (cached) {
// Cache HIT — trả về ngay (10-20ms thay vì 500-2000ms)
return ContentService.createTextOutput(cached).setMimeType(ContentService.MimeType.JSON);
}
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CV_SHEET_NAME);
if (!sheet) return jsonResponse({ ok: true, data: [] });
const data = sheet.getDataRange().getValues();
if (data.length < 2) return jsonResponse({ ok: true, data: [] });
const headers = data[0];
const rows = data.slice(1);
const result = rows.map((row, idx) => {
const obj = { _row: idx + 2 };
headers.forEach((h, i) => obj[h] = row[i] instanceof Date ? row[i].toISOString() : row[i]);
return obj;
}).filter(r => r['Tên']);
result.sort((a, b) => new Date(b.Timestamp) - new Date(a.Timestamp));
const response = JSON.stringify({ ok: true, data: result });
// Cache 60 giây — invalidated khi có upload/review/test mới
try { cache.put('cv_queue_v1', response, 60); } catch(e) {}
return ContentService.createTextOutput(response).setMimeType(ContentService.MimeType.JSON);
} catch (err) {
return jsonResponse({ ok: false, error: err.message });
}
}
// Helper: invalidate cache khi có thay đổi
function invalidateCVCache() {
try { CacheService.getScriptCache().remove('cv_queue_v1'); } catch(e) {}
}
// ============== REVIEW CV (TPMKT: REJECTED/POTENTIAL/SCHEDULED) ==============
function reviewCV(data) {
try {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CV_SHEET_NAME);
if (!sheet) return jsonResponse({ ok: false, error: 'Không tìm thấy sheet CVQueue' });
const row = parseInt(data.row);
if (!row || row < 2) return jsonResponse({ ok: false, error: 'Row không hợp lệ' });
sheet.getRange(row, 9).setValue(data.decision);
sheet.getRange(row, 10).setValue(data.leadName || '');
sheet.getRange(row, 11).setValue(data.leadNote || '');
if (data.interviewDate) sheet.getRange(row, 12).setValue(data.interviewDate);
sheet.getRange(row, 13).setValue(new Date());
invalidateCVCache();
// 🔔 Lark notify: TPMKT vừa duyệt
const candName = sheet.getRange(row, 2).getValue();
const position = sheet.getRange(row, 4).getValue();
const contact = sheet.getRange(row, 3).getValue();
const eventMap = { REJECTED: 'TPMKT_REJECTED', POTENTIAL: 'TPMKT_POTENTIAL', SCHEDULED: 'TPMKT_SCHEDULED' };
const eventType = eventMap[data.decision];
if (eventType) {
notifyLark(eventType, {
candidate: { name: candName, position: position, contact: contact },
note: data.leadNote,
interviewDate: data.interviewDate
});
}
return jsonResponse({ ok: true, row: row });
} catch (err) {
return jsonResponse({ ok: false, error: err.message });
}
}
// ============== SEND TEST (Tạo bài test + token unique) ==============
function sendTest(data) {
try {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CV_SHEET_NAME);
if (!sheet) return jsonResponse({ ok: false, error: 'Không tìm thấy sheet CVQueue' });
const row = parseInt(data.row);
// Đảm bảo có đủ cột cho test (cột 14-19): Test type, Test description, Test deadline, Test token, Test file, Test note, Test submitted at
ensureTestColumns(sheet);
// Sinh token unique
const token = Utilities.getUuid().replace(/-/g, '').slice(0, 24);
sheet.getRange(row, 9).setValue('TEST_SENT');
sheet.getRange(row, 14).setValue(data.testType || '');
sheet.getRange(row, 15).setValue(data.testDescription || '');
sheet.getRange(row, 16).setValue(data.testDeadline || '');
sheet.getRange(row, 17).setValue(token);
invalidateCVCache();
// 🔔 Lark notify: HR đã gửi bài test
const candName2 = sheet.getRange(row, 2).getValue();
const position2 = sheet.getRange(row, 4).getValue();
notifyLark('TEST_SENT', {
candidate: { name: candName2, position: position2 },
deadline: data.testDeadline
});
return jsonResponse({ ok: true, row: row, token: token });
} catch (err) {
return jsonResponse({ ok: false, error: err.message });
}
}
// ============== SUBMIT TEST (Ứng viên nộp bài qua /bai-test/?token=XYZ) ==============
function submitTest(data) {
try {
const folderId = getConfig('cv_drive_folder_id');
if (!folderId) {
return jsonResponse({ ok: false, error: 'Drive folder chưa cấu hình' });
}
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CV_SHEET_NAME);
if (!sheet) return jsonResponse({ ok: false, error: 'Không tìm thấy CVQueue' });
// Tìm row theo token
const row = findRowByToken(sheet, data.token);
if (!row) return jsonResponse({ ok: false, error: 'Token không hợp lệ' });
// Lưu file lên Drive
const folder = DriveApp.getFolderById(folderId);
const bytes = Utilities.base64Decode(data.fileBase64);
const blob = Utilities.newBlob(bytes, data.fileMimeType, data.fileName);
const candName = sheet.getRange(row, 2).getValue() || 'unknown';
const safeName = (candName + '').replace(/[^\w\s-]/g, '').replace(/\s+/g, '_');
const fileFinal = 'BaiTest_' + safeName + '_' + new Date().toISOString().slice(0,10) + '_' + data.fileName;
const file = folder.createFile(blob).setName(fileFinal);
try {
file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
} catch(shareErr) {
Logger.log('setSharing skipped: ' + shareErr.message);
}
sheet.getRange(row, 9).setValue('TEST_SUBMITTED');
sheet.getRange(row, 18).setValue(file.getUrl());
sheet.getRange(row, 19).setValue(data.note || '');
sheet.getRange(row, 20).setValue(new Date());
invalidateCVCache();
// 🔔 Lark notify: Ứng viên đã nộp bài test
notifyLark('TEST_SUBMITTED', {
candidate: { name: candName, position: sheet.getRange(row, 4).getValue() },
note: data.note
});
return jsonResponse({ ok: true, fileUrl: file.getUrl() });
} catch (err) {
return jsonResponse({ ok: false, error: err.message });
}
}
// ============== GET TEST INFO (cho ứng viên xem bài) ==============
function getTestInfo(token) {
try {
if (!token) return jsonResponse({ ok: false, error: 'Thiếu token' });
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CV_SHEET_NAME);
if (!sheet) return jsonResponse({ ok: false, error: 'Không tìm thấy CVQueue' });
const row = findRowByToken(sheet, token);
if (!row) return jsonResponse({ ok: false, error: 'Token không hợp lệ hoặc đã bị xoá' });
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const rowData = sheet.getRange(row, 1, 1, sheet.getLastColumn()).getValues()[0];
const obj = {};
headers.forEach((h, i) => {
obj[h] = rowData[i] instanceof Date ? rowData[i].toISOString() : rowData[i];
});
// Chỉ trả các field cần thiết cho ứng viên (KHÔNG trả Note Lead, Lead reviewer)
const safe = {
'Tên': obj['Tên'],
'Vị trí': obj['Vị trí'],
'Status': obj['Status'],
'Test type': obj['Test type'],
'Test description': obj['Test description'],
'Test deadline': obj['Test deadline'],
'Test submitted at': obj['Test submitted at']
};
return jsonResponse({ ok: true, data: safe });
} catch (err) {
return jsonResponse({ ok: false, error: err.message });
}
}
function ensureTestColumns(sheet) {
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const need = ['Test type','Test description','Test deadline','Test token','Test file','Test note','Test submitted at'];
let lastCol = headers.length;
need.forEach(h => {
if (headers.indexOf(h) === -1) {
lastCol++;
sheet.getRange(1, lastCol).setValue(h);
}
});
}
function ensureHrScoreColumns(sheet) {
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const need = ['HR Score', 'HR Score Detail'];
let lastCol = headers.length;
need.forEach(h => {
if (headers.indexOf(h) === -1) {
lastCol++;
sheet.getRange(1, lastCol).setValue(h);
}
});
}
function ensurePortfolioColumns(sheet) {
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const need = ['Portfolio link', 'Portfolio file'];
let lastCol = headers.length;
need.forEach(h => {
if (headers.indexOf(h) === -1) {
lastCol++;
sheet.getRange(1, lastCol).setValue(h);
}
});
}
function findRowByToken(sheet, token) {
const data = sheet.getDataRange().getValues();
const headers = data[0];
const tokenCol = headers.indexOf('Test token');
if (tokenCol === -1) return 0;
for (let i = 1; i < data.length; i++) {
if (data[i][tokenCol] === token) return i + 1;
}
return 0;
}
// ============== GET: Tải danh sách hồ sơ HOẶC CV queue ==============
function doGet(e) {
try {
if (e.parameter.apiKey !== API_KEY) {
return jsonResponse({ ok: false, error: 'Sai API key' });
}
// Action: list_cv_queue
if (e.parameter.action === 'list_cv_queue') {
return listCVQueue();
}
// Action: get_test_info (cho trang public /bai-test/?token=XYZ)
if (e.parameter.action === 'get_test_info') {
return getTestInfo(e.parameter.token);
}
// Action: get_config (cho /quan-tri/ load config hiện tại)
if (e.parameter.action === 'get_config') {
refreshConfigCache();
return jsonResponse({ ok: true, config: _configCache || {} });
}
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
const data = sheet.getDataRange().getValues();
if (data.length < 2) return jsonResponse({ ok: true, data: [] });
const headers = data[0];
const rows = data.slice(1);
let result = rows.map((row, idx) => {
const obj = { _row: idx + 2 };
headers.forEach((h, i) => obj[h] = row[i]);
return obj;
}).filter(r => r['Tên ứng viên']);
// Lọc theo tên nếu có
if (e.parameter.search) {
const q = e.parameter.search.toLowerCase();
result = result.filter(r =>
((r['Tên ứng viên'] || '') + '').toLowerCase().includes(q) ||
((r['SĐT/Email'] || '') + '').toLowerCase().includes(q)
);
}
// Trả về 50 dòng mới nhất
result.sort((a, b) => new Date(b.Timestamp) - new Date(a.Timestamp));
if (result.length > 50) result = result.slice(0, 50);
return jsonResponse({ ok: true, data: result, total: result.length });
} catch (err) {
return jsonResponse({ ok: false, error: err.message });
}
}
function findExistingRow(sheet, name, exportType) {
if (!name) return 0;
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][1] === name && data[i][5] === exportType) return i + 1;
}
return 0;
}
function jsonResponse(obj) {
return ContentService.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}
// ============== TEST LARK WEBHOOK (Apps Script proxy — bypass CORS) ==============
function testLarkWebhook(data) {
const webhook = (data.webhook || '').trim() || getConfig('lark_webhook');
if (!webhook) {
return jsonResponse({ ok: false, error: 'Chưa có Lark webhook URL' });
}
const card = {
msg_type: 'interactive',
card: {
header: {
title: { tag: 'plain_text', content: '🧪 TEST từ KITA Interview System' },
template: 'turquoise'
},
elements: [
{ tag: 'div', text: { tag: 'lark_md', content: '✅ **Bot hoạt động!**\n\nNếu bạn thấy tin nhắn này → cấu hình Lark đã ĐÚNG.\nTừ giờ mọi thay đổi trạng thái CV/PV sẽ tự động gửi notify vào group này.' } },
{ tag: 'hr' },
{ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: '📋 Mở hệ thống' }, url: getConfig('system_url') || 'https://phongvanmkt.kitagroupvn.com', type: 'primary' }] }
]
}
};
try {
const res = UrlFetchApp.fetch(webhook, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(card),
muteHttpExceptions: true
});
const code = res.getResponseCode();
const body = res.getContentText();
if (code === 200) {
return jsonResponse({ ok: true, message: 'Đã gửi tin test vào Lark thành công' });
}
return jsonResponse({ ok: false, error: 'Lark trả về HTTP ' + code + ': ' + body.slice(0, 200) });
} catch(e) {
return jsonResponse({ ok: false, error: e.message });
}
}
// ============== UPDATE CONFIG (qua /quan-tri/ web) ==============
function updateConfig(data) {
try {
let sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG_SHEET);
if (!sheet) {
sheet = SpreadsheetApp.getActiveSpreadsheet().insertSheet(CONFIG_SHEET);
sheet.appendRow(['Key', 'Value', 'Description']);
// Pre-fill các key thông dụng
sheet.appendRow(['cv_drive_folder_id', '', 'ID folder Drive lưu CV']);
sheet.appendRow(['lark_webhook', '', 'Lark Bot webhook URL']);
sheet.appendRow(['lark_email_hr', '', 'Email Lark của HR']);
sheet.appendRow(['lark_email_tpmkt', '', 'Email Lark của TPMKT']);
sheet.appendRow(['lark_email_director', '', 'Email Lark của GĐ']);
sheet.appendRow(['system_url', '', 'URL hệ thống (vd: https://phongvanmkt.kitagroupvn.com)']);
}
const allData = sheet.getDataRange().getValues();
const existing = {};
for (let i = 1; i < allData.length; i++) {
if (allData[i][0]) existing[String(allData[i][0]).trim()] = i + 1;
}
Object.entries(data.config || {}).forEach(([key, val]) => {
if (existing[key]) {
sheet.getRange(existing[key], 2).setValue(val);
} else {
sheet.appendRow([key, val, '']);
}
});
// Xoá cache để lần đọc tiếp theo lấy giá trị mới
_configCache = null;
return jsonResponse({ ok: true, message: 'Đã lưu. Có hiệu lực ngay (cache làm mới mỗi 60s).' });
} catch (err) {
return jsonResponse({ ok: false, error: err.message });
}
}
// ============== LARK BOT NOTIFICATION ==============
function notifyLark(eventType, data) {
const webhook = getConfig('lark_webhook');
if (!webhook) return; // Chưa cấu hình → bỏ qua
const sysUrl = getConfig('system_url') || 'https://phongvanmkt.kitagroupvn.com';
const emailHr = getConfig('lark_email_hr');
const emailTpmkt = getConfig('lark_email_tpmkt');
const emailDirector = getConfig('lark_email_director');
const events = {
'NEW_CV': {
title: '📥 CV mới chờ TPMKT duyệt',
template: 'blue', mention: emailTpmkt,
url: sysUrl + '/cv/', btn: '✍️ Vào duyệt CV'
},
'TPMKT_REJECTED': {
title: '❌ TPMKT đã loại CV',
template: 'red', mention: emailHr,
url: sysUrl + '/cv/', btn: '📋 Xem chi tiết'
},
'TPMKT_POTENTIAL': {
title: '🟡 CV được đánh dấu Tiềm năng',
template: 'yellow', mention: emailHr,
url: sysUrl + '/cv/', btn: '📋 Xem chi tiết'
},
'TPMKT_SCHEDULED': {
title: '🟢 TPMKT hẹn PV V1 - HR triển khai',
template: 'green', mention: emailHr,
url: sysUrl + '/hr/', btn: '👤 Vào phỏng vấn V1'
},
'TEST_SENT': {
title: '📝 Đã gửi bài test cho ứng viên',
template: 'purple', mention: '',
url: sysUrl + '/cv/', btn: '📋 Xem chi tiết'
},
'TEST_SUBMITTED': {
title: '✅ Ứng viên ĐÃ NỘP BÀI TEST',
template: 'green',
mention: [emailTpmkt, emailHr].filter(Boolean).join(','),
url: sysUrl + '/', btn: '📊 Vào chấm bài'
}
};
const ev = events[eventType];
if (!ev) return;
const cand = data.candidate || {};
const lines = [];
lines.push('**👤 Ứng viên:** ' + (cand.name || '?'));
if (cand.position) lines.push('**💼 Vị trí:** ' + cand.position);
if (cand.contact) lines.push('**📞 Liên hệ:** ' + cand.contact);
if (data.note) lines.push('**📝 Ghi chú:** ' + data.note);
if (data.interviewDate) lines.push('**📅 Lịch PV:** ' + data.interviewDate);
if (data.deadline) lines.push('**⏰ Deadline test:** ' + data.deadline);
// Build mention(s)
let mentionStr = '';
if (ev.mention) {
const emails = ev.mention.split(',').map(e => e.trim()).filter(Boolean);
mentionStr = emails.map(e => `
`).join(' ') + '\n\n';
}
const card = {
msg_type: 'interactive',
card: {
header: {
title: { tag: 'plain_text', content: ev.title },
template: ev.template
},
elements: [
{ tag: 'div', text: { tag: 'lark_md', content: mentionStr + lines.join('\n') } },
{ tag: 'hr' },
{
tag: 'action',
actions: [
{
tag: 'button',
text: { tag: 'plain_text', content: ev.btn },
url: ev.url,
type: 'primary'
}
]
}
]
}
};
try {
UrlFetchApp.fetch(webhook, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(card),
muteHttpExceptions: true
});
} catch(e) {
Logger.log('Lark notify failed: ' + e.message);
}
}