Files
schoolhub/app/Code.gs
T
2026-04-24 23:44:47 +00:00

661 lines
21 KiB
JavaScript

/**
* SchoolHub Launcher - Backend Logic
* Core architecture: Google Sheets as Database
*/
const SPREADSHEET_ID = '1oWvHLC2tnLHHO36IxTErSCuvDWobsbI_LTlQwtMYiZU';
const APP_ID = 'launcher';
// --- CORE ENTRY POINT ---
function doGet() {
return HtmlService
.createTemplateFromFile('Index')
.evaluate()
.setTitle('SchoolHub Launcher')
.addMetaTag('viewport', 'width=device-width, initial-scale=1')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
function include(filename) {
return HtmlService.createHtmlOutputFromFile(filename).getContent();
}
// --- DATABASE SETUP ---
function setupDatabase() {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheets = [
{ name: 'Users', headers: ['email', 'name', 'phone', 'status', 'unit', 'isAdmin', 'createdAt', 'updatedAt'] },
{ name: 'Access', headers: ['email', 'appId', 'roles', 'active', 'notes', 'updatedAt'] },
{ name: 'Apps', headers: ['appId', 'appName', 'appUrl', 'active', 'sortOrder'] },
{ name: 'AuditLog', headers: ['timestamp', 'email', 'appId', 'action', 'status', 'message', 'actor'] },
{ name: 'OTP', headers: ['phone', 'code', 'expiresAt'] },
{ name: 'Sessions', headers: ['sessionId', 'email', 'expiresAt'] },
{ name: 'SystemDocs', headers: ['docType', 'content', 'updatedAt'] }
];
sheets.forEach(({ name, headers }) => {
let sheet = ss.getSheetByName(name);
if (!sheet) sheet = ss.insertSheet(name);
// Always update headers to ensure schema is current
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
});
return { ok: true, message: 'Database setup complete. Sheets and headers initialized.' };
}
// --- JSON SERIALIZATION HELPERS ---
function serializeForClient(value) {
if (value === null || value === undefined) return null;
if (Object.prototype.toString.call(value) === '[object Date]') {
return value.toISOString();
}
if (Array.isArray(value)) {
return value.map(serializeForClient);
}
if (typeof value === 'object') {
const out = {};
Object.keys(value).forEach((k) => {
out[k] = serializeForClient(value[k]);
});
return out;
}
return value;
}
// --- UTILITY HELPERS ---
function normalizeEmail(email) {
if (!email) return '';
return String(email).trim().toLowerCase();
}
function normalizeBoolString(value) {
return String(value).toUpperCase() === 'TRUE' ? 'TRUE' : 'FALSE';
}
function isTrue_(value) {
return String(value).toUpperCase() === 'TRUE';
}
function toSafeText_(value) {
return value === null || value === undefined ? '' : String(value).trim();
}
function getCurrentUserEmail() {
return normalizeEmail(Session.getActiveUser().getEmail());
}
function rowsToObjects_(values) {
if (!values || values.length < 2) return [];
const headers = values[0];
const out = [];
for (let i = 1; i < values.length; i++) {
const row = values[i];
const obj = {};
for (let c = 0; c < headers.length; c++) obj[headers[c]] = row[c];
out.push(obj);
}
return out;
}
// --- PRIVATE DATABASE READERS ---
function readUsers_(ss) {
const sheet = ss.getSheetByName('Users');
if (!sheet) return {};
const rows = rowsToObjects_(sheet.getDataRange().getValues());
const map = {};
rows.forEach((obj) => {
const email = normalizeEmail(obj.email);
if (email) map[email] = obj;
});
return map;
}
function readUsersByEmail_(ss, email) {
const users = readUsers_(ss);
return users[normalizeEmail(email)] || null;
}
function readApps_(ss) {
const sheet = ss.getSheetByName('Apps');
if (!sheet) return [];
return rowsToObjects_(sheet.getDataRange().getValues())
.filter(app => isTrue_(app.active))
.map(app => ({
appId: toSafeText_(app.appId),
appName: toSafeText_(app.appName),
appUrl: toSafeText_(app.appUrl),
active: true,
sortOrder: Number(app.sortOrder || 0)
}));
}
function readAccessRow_(ss, email, appId) {
const sheet = ss.getSheetByName('Access');
if (!sheet) return null;
const rows = rowsToObjects_(sheet.getDataRange().getValues());
return rows.find(r => normalizeEmail(r.email) === normalizeEmail(email) && String(r.appId) === String(appId)) || null;
}
function readAccessByEmail_(ss, email) {
const sheet = ss.getSheetByName('Access');
if (!sheet) return [];
return rowsToObjects_(sheet.getDataRange().getValues())
.filter(r => normalizeEmail(r.email) === normalizeEmail(email));
}
// --- AUTH & PERMISSIONS LOGIC ---
function getAuthCacheKey_(email, appId) {
return `auth_${appId}_${email}`;
}
function clearAuthCache_(email, appId = APP_ID) {
CacheService.getScriptCache().remove(getAuthCacheKey_(email, appId));
}
function isAllowed(email, appId) {
if (!email) return { isAllowed: false, reason: 'Email is missing.' };
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const ownerEmail = normalizeEmail(ss.getOwner().getEmail());
const users = readUsers_(ss);
let user = users[email];
// Auto-admin for owner
if (!user && email === ownerEmail) {
const userSheet = ss.getSheetByName('Users');
const now = new Date();
userSheet.appendRow([email, 'Owner Admin', '', 'active', 'System', 'TRUE', now, now]);
user = { email, status: 'active', isAdmin: 'TRUE' };
clearAuthCache_(email, appId);
}
if (!user) return { isAllowed: false, reason: 'Email not whitelisted.' };
if (String(user.status || '').toLowerCase() !== 'active') return { isAllowed: false, reason: 'Account is inactive.' };
if (isTrue_(user.isAdmin)) return { isAllowed: true, reason: '' };
if (appId === APP_ID) return { isAllowed: true, reason: '' };
const access = readAccessRow_(ss, email, appId);
if (!access || !isTrue_(access.active)) return { isAllowed: false, reason: 'No active access for this app.' };
return { isAllowed: true, reason: '' };
}
function getAuthContext(email, appId) {
const cache = CacheService.getScriptCache();
const cacheKey = getAuthCacheKey_(email, appId);
const cached = cache.get(cacheKey);
if (cached) return JSON.parse(cached);
const allowed = isAllowed(email, appId);
if (!allowed.isAllowed) {
const denied = { isAllowed: false, reason: allowed.reason, roles: [] };
cache.put(cacheKey, JSON.stringify(denied), 300);
return denied;
}
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const userRow = readUsersByEmail_(ss, email);
const accessRow = readAccessRow_(ss, email, appId);
const roles = accessRow && accessRow.roles
? String(accessRow.roles).split(',').map(r => r.trim()).filter(Boolean)
: (userRow && isTrue_(userRow.isAdmin) ? ['admin'] : []);
const result = {
isAllowed: true,
user: {
email: userRow ? userRow.email : email,
name: userRow ? (userRow.name || '') : '',
unit: userRow ? (userRow.unit || '') : ''
},
roles,
permissions: {
canManageUsers: roles.includes('admin') || (userRow && isTrue_(userRow.isAdmin)),
canEditApp: roles.includes('editor') || roles.includes('admin'),
canViewApp: true
}
};
cache.put(cacheKey, JSON.stringify(result), 300);
return result;
}
function getAllowedApps(email) {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const apps = readApps_(ss);
const user = readUsersByEmail_(ss, email);
if (user && isTrue_(user.isAdmin)) {
return apps.sort((a, b) => a.sortOrder - b.sortOrder);
}
const accessRows = readAccessByEmail_(ss, email);
const allowedIds = new Set(accessRows.filter(r => isTrue_(r.active)).map(r => String(r.appId)));
return apps
.filter(a => allowedIds.has(String(a.appId)))
.sort((a, b) => a.sortOrder - b.sortOrder);
}
// --- PUBLIC API: FRONTEND DATA ---
function getInitialData() {
try {
const email = getCurrentUserEmail();
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const userSheet = ss.getSheetByName('Users');
const isSystemEmpty = !userSheet || userSheet.getLastRow() < 2;
if (isSystemEmpty) {
return { ok: true, data: serializeForClient({ isSystemEmpty: true, userEmail: email }) };
}
if (!email) return { ok: true, data: serializeForClient({ needsAuth: true }) };
const auth = getAuthContext(email, APP_ID);
if (!auth.isAllowed) {
logAccess({ email, appId: APP_ID, action: 'ACCESS_DENIED', status: 'FAILURE', message: auth.reason });
return { ok: false, message: auth.reason };
}
const apps = getAllowedApps(email);
logAccess({ email, appId: APP_ID, action: 'ACCESS_GRANTED', status: 'SUCCESS', message: 'User loaded launcher' });
return {
ok: true,
data: serializeForClient({
user: auth.user,
apps,
isAdmin: auth.roles.includes('admin')
})
};
} catch (e) {
return { ok: false, message: e.message };
}
}
function registerFirstAdmin(name) {
try {
const email = getCurrentUserEmail();
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName('Users');
if (sheet && sheet.getLastRow() >= 2) throw new Error('System already initialized.');
if (!email) throw new Error('User email not available.');
const now = new Date();
sheet.appendRow([email, toSafeText_(name), '', 'active', 'System Admin', 'TRUE', now, now]);
clearAuthCache_(email);
return { ok: true };
} catch (e) {
return { ok: false, message: e.message };
}
}
// --- PUBLIC API: ADMIN USER MANAGEMENT ---
function getAdminUsers() {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const values = ss.getSheetByName('Users').getDataRange().getValues();
return { ok: true, data: serializeForClient(rowsToObjects_(values)) };
} catch (e) {
return { ok: false, message: e.message };
}
}
function saveUser(userData) {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const targetEmail = normalizeEmail(userData.email);
if (!targetEmail || !userData.name) throw new Error('Email and Name are required');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName('Users');
const values = sheet.getDataRange().getValues();
const headers = values[0];
const emailIdx = headers.indexOf('email');
let rowIndex = -1;
for (let i = 1; i < values.length; i++) {
if (normalizeEmail(values[i][emailIdx]) === targetEmail) {
rowIndex = i + 1;
break;
}
}
const now = new Date();
const rowValue = headers.map(h => {
if (h === 'email') return targetEmail;
if (h === 'name') return toSafeText_(userData.name);
if (h === 'status') return toSafeText_(userData.status).toLowerCase() === 'inactive' ? 'inactive' : 'active';
if (h === 'unit') return toSafeText_(userData.unit);
if (h === 'isAdmin') return normalizeBoolString(userData.isAdmin);
if (h === 'updatedAt') return now;
if (h === 'createdAt' && rowIndex > 0) return values[rowIndex - 1][headers.indexOf('createdAt')];
if (h === 'createdAt') return now;
return toSafeText_(userData[h]);
});
if (rowIndex > 0) sheet.getRange(rowIndex, 1, 1, rowValue.length).setValues([rowValue]);
else sheet.appendRow(rowValue);
clearAuthCache_(targetEmail);
return { ok: true };
} catch (e) {
return { ok: false, message: e.message };
}
}
function deleteUser(targetEmail) {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const userSheet = ss.getSheetByName('Users');
const accessSheet = ss.getSheetByName('Access');
const normalizedTarget = normalizeEmail(targetEmail);
const deleteByEmail = (sheet) => {
const data = sheet.getDataRange().getValues();
const emailIdx = data[0].indexOf('email');
for (let i = data.length - 1; i >= 1; i--) {
if (normalizeEmail(data[i][emailIdx]) === normalizedTarget) sheet.deleteRow(i + 1);
}
};
deleteByEmail(userSheet);
deleteByEmail(accessSheet);
clearAuthCache_(normalizedTarget);
return { ok: true };
} catch (e) {
return { ok: false, message: e.message };
}
}
// --- PUBLIC API: ADMIN APP MANAGEMENT ---
function getAdminApps() {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const values = ss.getSheetByName('Apps').getDataRange().getValues();
return { ok: true, data: serializeForClient(rowsToObjects_(values)) };
} catch (e) {
return { ok: false, message: e.message };
}
}
function saveApp(appData) {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const appId = toSafeText_(appData.appId);
if (!appId || !appData.appName || !appData.appUrl) throw new Error('App ID, Name, and URL are required');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName('Apps');
const values = sheet.getDataRange().getValues();
const headers = values[0];
const appIdIdx = headers.indexOf('appId');
let rowIndex = -1;
for (let i = 1; i < values.length; i++) {
if (String(values[i][appIdIdx]) === appId) {
rowIndex = i + 1;
break;
}
}
const rowValue = headers.map(h => {
if (h === 'appId') return appId;
if (h === 'appName') return toSafeText_(appData.appName);
if (h === 'appUrl') return toSafeText_(appData.appUrl);
if (h === 'active') return normalizeBoolString(appData.active);
if (h === 'sortOrder') return Number(appData.sortOrder || 0);
return '';
});
if (rowIndex > 0) sheet.getRange(rowIndex, 1, 1, rowValue.length).setValues([rowValue]);
else sheet.appendRow(rowValue);
return { ok: true };
} catch (e) {
return { ok: false, message: e.message };
}
}
function deleteApp(appId) {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName('Apps');
const data = sheet.getDataRange().getValues();
const appIdIdx = data[0].indexOf('appId');
for (let i = data.length - 1; i >= 1; i--) {
if (String(data[i][appIdIdx]) === String(appId)) {
sheet.deleteRow(i + 1);
return { ok: true };
}
}
throw new Error('App not found');
} catch (e) {
return { ok: false, message: e.message };
}
}
// --- PUBLIC API: ACCESS MANAGEMENT ---
function getAccessForUser(email) {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
return {
ok: true,
data: serializeForClient({
userAccess: readAccessByEmail_(ss, email),
availableApps: readApps_(ss)
})
};
} catch (e) {
return { ok: false, message: e.message };
}
}
function saveAccess(accessData) {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const targetEmail = normalizeEmail(accessData.email);
const appId = toSafeText_(accessData.appId);
if (!targetEmail || !appId) throw new Error('Email and App ID are required');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName('Access');
const values = sheet.getDataRange().getValues();
const headers = values[0];
const emailIdx = headers.indexOf('email');
const appIdIdx = headers.indexOf('appId');
let rowIndex = -1;
for (let i = 1; i < values.length; i++) {
if (normalizeEmail(values[i][emailIdx]) === targetEmail && String(values[i][appIdIdx]) === appId) {
rowIndex = i + 1;
break;
}
}
const rowValue = headers.map(h => {
if (h === 'email') return targetEmail;
if (h === 'appId') return appId;
if (h === 'roles') return toSafeText_(accessData.roles);
if (h === 'active') return normalizeBoolString(accessData.active);
if (h === 'notes') return toSafeText_(accessData.notes);
if (h === 'updatedAt') return new Date();
return '';
});
if (rowIndex > 0) sheet.getRange(rowIndex, 1, 1, rowValue.length).setValues([rowValue]);
else sheet.appendRow(rowValue);
clearAuthCache_(targetEmail);
return { ok: true };
} catch (e) {
return { ok: false, message: e.message };
}
}
function deleteAccess(email, appId) {
try {
const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName('Access');
const data = sheet.getDataRange().getValues();
const emailIdx = data[0].indexOf('email');
const appIdIdx = data[0].indexOf('appId');
for (let i = data.length - 1; i >= 1; i--) {
if (normalizeEmail(data[i][emailIdx]) === normalizeEmail(email) && String(data[i][appIdIdx]) === String(appId)) {
sheet.deleteRow(i + 1);
}
}
clearAuthCache_(email);
return { ok: true };
} catch (e) {
return { ok: false, message: e.message };
}
}
// --- PUBLIC API: AUTHENTICATION & OTP ---
const GO_WHATSAPP_API_KEY = 'YOUR_API_KEY';
const GO_WHATSAPP_URL = 'https://api.gowhatsapp.com/send';
function sendOTP(phone) {
try {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const users = rowsToObjects_(ss.getSheetByName('Users').getDataRange().getValues());
if (!users.find(u => String(u.phone).trim() === String(phone).trim())) {
throw new Error('Nomor WhatsApp Anda belum terdaftar.');
}
const code = Math.floor(100000 + Math.random() * 900000).toString();
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
const otpSheet = ss.getSheetByName('OTP');
const otpData = otpSheet.getDataRange().getValues();
for (let i = otpData.length - 1; i >= 1; i--) {
if (otpData[i][0] === phone) otpSheet.deleteRow(i + 1);
}
otpSheet.appendRow([phone, code, expiresAt]);
UrlFetchApp.fetch(GO_WHATSAPP_URL, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({
apiKey: GO_WHATSAPP_API_KEY,
phone: phone,
message: `Kode OTP SchoolHub Anda adalah: ${code}. Berlaku selama 5 menit.`
}),
muteHttpExceptions: true
});
return { ok: true, message: 'OTP telah dikirim ke WhatsApp Anda.' };
} catch (e) {
return { ok: false, message: e.message };
}
}
function verifyOTP(phone, code) {
try {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const otpData = ss.getSheetByName('OTP').getDataRange().getValues();
const validOtp = otpData.find(r => r[0] === phone && String(r[1]) === String(code) && new Date(r[2]) > new Date());
if (!validOtp) throw new Error('Kode OTP salah atau kadaluwarsa.');
const users = rowsToObjects_(ss.getSheetByName('Users').getDataRange().getValues());
const user = users.find(u => String(u.phone).trim() === String(phone).trim());
if (!user) throw new Error('User tidak ditemukan.');
const sessionId = Utilities.getUuid();
ss.getSheetByName('Sessions').appendRow([sessionId, user.email, new Date(Date.now() + 24 * 60 * 60 * 1000)]);
return { ok: true, data: { sessionId, email: user.email } };
} catch (e) {
return { ok: false, message: e.message };
}
}
function loginWithGoogle() {
try {
const email = getCurrentUserEmail();
if (!email) return { ok: false, message: 'Email tidak terdeteksi.' };
const auth = isAllowed(email, APP_ID);
if (!auth.isAllowed) return { ok: false, message: auth.reason };
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sessionSheet = ss.getSheetByName('Sessions');
if (!sessionSheet) {
throw new Error('System Error: "Sessions" sheet not found. Please run setupDatabase().');
}
const sessionId = Utilities.getUuid();
sessionSheet.appendRow([sessionId, email, new Date(Date.now() + 24 * 60 * 60 * 1000)]);
return { ok: true, data: { sessionId, email } };
} catch (e) {
return { ok: false, message: e.message };
}
}
// --- LOGGING ---
function logAccess(event) {
try {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheetByName('AuditLog');
if (!sheet) return;
sheet.appendRow([
new Date(),
event.email || '',
event.appId || '',
event.action || '',
event.status || '',
event.message || '',
event.actor || 'System'
]);
} catch (e) {
console.error('Logging failed: ' + e.message);
}
}