From d2febe2b18bcfe21fd55fdaa601509579b688bcc Mon Sep 17 00:00:00 2001 From: someone <2+someone@noreply.localhost> Date: Fri, 24 Apr 2026 23:44:44 +0000 Subject: [PATCH 01/13] Sync appsscript.json --- appsscript.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 appsscript.json diff --git a/appsscript.json b/appsscript.json new file mode 100644 index 0000000..ec36824 --- /dev/null +++ b/appsscript.json @@ -0,0 +1,14 @@ +{ + "timeZone": "Asia/Bangkok", + "dependencies": {}, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8", + "webapp": { + "executeAs": "USER_DEPLOYING", + "access": "ANYONE_ANONYMOUS" + }, + "oauthScopes": [ + "https://www.googleapis.com/auth/script.projects.readonly", + "https://www.googleapis.com/auth/script.external_request" + ] +} \ No newline at end of file -- 2.52.0 From fc34eb502ddc2e59d869be1e304afb5fc346695e Mon Sep 17 00:00:00 2001 From: someone <2+someone@noreply.localhost> Date: Fri, 24 Apr 2026 23:44:47 +0000 Subject: [PATCH 02/13] Sync app/Code.gs --- app/Code.gs | 660 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 app/Code.gs diff --git a/app/Code.gs b/app/Code.gs new file mode 100644 index 0000000..8418f62 --- /dev/null +++ b/app/Code.gs @@ -0,0 +1,660 @@ +/** + * 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); + } +} -- 2.52.0 From 1fc25ff1d87d5a9083b870eeb82735228d9b4433 Mon Sep 17 00:00:00 2001 From: someone <2+someone@noreply.localhost> Date: Fri, 24 Apr 2026 23:44:49 +0000 Subject: [PATCH 03/13] Sync app/Index.html --- app/Index.html | 912 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 912 insertions(+) create mode 100644 app/Index.html diff --git a/app/Index.html b/app/Index.html new file mode 100644 index 0000000..461e6af --- /dev/null +++ b/app/Index.html @@ -0,0 +1,912 @@ + + + + + + + + + + + + + + + + + +
+ + + + +
+======= +
+ + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + +
+
+
+ + + + + + +
+
+ + +
+
+
+ + +
+
+
+ + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + + + +
+
+ + +
+
+
+ + + + +
+
+
+
+ + + + \ No newline at end of file -- 2.52.0 From dbb2c8891be1e4cdd2953b4a0bc6ff5f4399def8 Mon Sep 17 00:00:00 2001 From: someone <2+someone@noreply.localhost> Date: Fri, 24 Apr 2026 23:44:51 +0000 Subject: [PATCH 04/13] Sync app/styles.html --- app/styles.html | 222 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 app/styles.html diff --git a/app/styles.html b/app/styles.html new file mode 100644 index 0000000..1b776fc --- /dev/null +++ b/app/styles.html @@ -0,0 +1,222 @@ + -- 2.52.0 From 8301255dd6e55bd5c7c620cd62eebd080799f3e3 Mon Sep 17 00:00:00 2001 From: someone <2+someone@noreply.localhost> Date: Fri, 24 Apr 2026 23:44:53 +0000 Subject: [PATCH 05/13] Sync app/scripts.html --- app/scripts.html | 507 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 app/scripts.html diff --git a/app/scripts.html b/app/scripts.html new file mode 100644 index 0000000..e60f5e0 --- /dev/null +++ b/app/scripts.html @@ -0,0 +1,507 @@ + + -- 2.52.0 From 80f5e33f36dbcc0976ce6e4bf74eab320d3c9fe9 Mon Sep 17 00:00:00 2001 From: someone <2+someone@noreply.localhost> Date: Fri, 24 Apr 2026 23:44:55 +0000 Subject: [PATCH 06/13] Sync docs/Readme.gs --- docs/Readme.gs | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/Readme.gs diff --git a/docs/Readme.gs b/docs/Readme.gs new file mode 100644 index 0000000..432156c --- /dev/null +++ b/docs/Readme.gs @@ -0,0 +1,92 @@ +/** + * ๐Ÿ“˜ SCHOOLHUB LAUNCHER - PROJECT DOCUMENTATION + * ============================================================================= + * + * 1. OVERVIEW + * ----------- + * SchoolHub Launcher is a centralized portal designed for school environments. + * It allows administrators to manage a list of internal applications and + * control which users have access to which apps based on their roles. + * + * CORE STACK: + * - Backend: Google Apps Script (GAS) + * - Database: Google Sheets + * - Frontend: Alpine.js, Tailwind CSS, Framework7 (Mobile UI) + * - Auth: Google Account & WhatsApp OTP (via GoWhatsApp API) + * + * 2. QUICK START SETUP + * -------------------- + * To get the application running for the first time: + * + * STEP 1: Spreadsheet Connection + * - Ensure the SPREADSHEET_ID in Code.gs matches your target Google Sheet ID. + * + * STEP 2: Database Initialization + * - In the Script Editor, select the function `setupDatabase` from the dropdown. + * - Click "Run". This creates all necessary tabs (Users, Access, Apps, etc.) + * and sets up the header rows. + * + * STEP 3: Deployment + * - Click "Deploy" -> "New Deployment". + * - Select "Web App". + * - Execute as: "Me". + * - Who has access: "Anyone" (The app handles its own internal authentication). + * + * STEP 4: First Admin + * - Open the Web App URL. + * - Since the system is empty, it will prompt you to register as the First Administrator. + * + * 3. DATABASE SCHEMA (Google Sheets) + * ---------------------------------- + * The system relies on these specific tabs: + * + * | Tab Name | Purpose | Key Columns | + * |-------------|----------------------------------------------|-----------------------------------| + * | Users | Registered users and global admin status | email, name, status, isAdmin | + * | Access | App-specific permissions (RBAC) | email, appId, roles, active | + * | Apps | Registry of available applications | appId, appName, appUrl, sortOrder | + * | AuditLog | History of access and admin actions | timestamp, email, action, status | + * | OTP | Temporary storage for WhatsApp verification | phone, code, expiresAt | + * | Sessions | Active session tokens | sessionId, email, expiresAt | + * | SystemDocs | Global system documentation/settings | docType, content, updatedAt | + * + * 4. CONFIGURATION + * ----------------- + * To modify system behavior, look for these constants in Code.gs: + * + * - SPREADSHEET_ID: The ID of the Google Sheet used as the DB. + * - GO_WHATSAPP_API_KEY: Your API key for the GoWhatsApp service. + * - GO_WHATSAPP_URL: The endpoint for sending WhatsApp messages. + * + * 5. PERMISSION LOGIC (RBAC) + * -------------------------- + * - Global Admin: If a user has `isAdmin = TRUE` in the 'Users' sheet, they + * can see ALL apps and access the Admin Dashboard. + * - App Access: For non-admins, the system checks the 'Access' sheet. + * A user must have an 'active' row matching the `appId` to see that app. + * - Roles: Roles (viewer, editor, admin) are stored in the 'Access' sheet + * and can be passed to the target app to handle internal permissions. + * + * 6. MAINTENANCE & TROUBLESHOOTING + * -------------------------------- + * - Cache Issues: The system uses CacheService for performance. If you + * manually edit the Sheet and don't see changes, wait 5 minutes or + * restart the browser. + * - OTP Failures: Ensure the phone number in the 'Users' sheet matches + * the number used during login (including country code). + * - Deployment: Whenever you change Code.gs, remember to create a + * "New Deployment" or update the "Active" deployment to see changes. + * + * 7. CHANGELOG + * ------------- + * [2023-10-27] - v1.1.0 + * - Fitur: Tambah sistem OTP WhatsApp. + * - Perbaikan: Fix bug pada filter kategori user. + * - Perubahan: Update UI Navbar menjadi lebih minimalis. + * + * [2023-10-20] - v1.0.0 + * - Initial Release: Dashboard, User Management, App Launcher. + * + * + * ============================================================================= + */ -- 2.52.0 From 5dbf81ca0fa3d3c1e92e45cb2661d43d943fb4f4 Mon Sep 17 00:00:00 2001 From: someone <2+someone@noreply.localhost> Date: Fri, 24 Apr 2026 23:44:57 +0000 Subject: [PATCH 07/13] Sync workflow/WorkflowConfig.gs --- workflow/WorkflowConfig.gs | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 workflow/WorkflowConfig.gs diff --git a/workflow/WorkflowConfig.gs b/workflow/WorkflowConfig.gs new file mode 100644 index 0000000..ba2ac51 --- /dev/null +++ b/workflow/WorkflowConfig.gs @@ -0,0 +1,46 @@ +/** + * WorkflowConfig - Centralized configuration management + * Uses PropertiesService to keep secrets out of the codebase. + */ + +function getWorkflowConfig() { + const props = PropertiesService.getScriptProperties().getProperties(); + return props; +} + +function getRequiredProperty_(key) { + const value = PropertiesService.getScriptProperties().getProperty(key); + if (!value) { + throw new Error(`Missing required configuration property: ${key}. Please set it in Project Settings > Script Properties.`); + } + return value; +} + +function getOptionalProperty_(key, fallback) { + const value = PropertiesService.getScriptProperties().getProperty(key); + return value !== null ? value : fallback; +} + +function getVikunjaConfig() { + return { + baseUrl: getRequiredProperty_('VIKUNJA_BASE_URL'), + token: getRequiredProperty_('VIKUNJA_TOKEN'), + projectId: getOptionalProperty_('VIKUNJA_DEFAULT_PROJECT_ID', null) + }; +} + +function getTestProjectConfig() { + return { + testProjectId: getOptionalProperty_('VIKUNJA_TEST_PROJECT_ID', null) + }; +} + +function getGiteaConfig() { + return { + baseUrl: getRequiredProperty_('GITEA_BASE_URL'), + token: getRequiredProperty_('GITEA_TOKEN'), + owner: getRequiredProperty_('GITEA_OWNER'), + repo: getRequiredProperty_('GITEA_REPO'), + branch: getOptionalProperty_('GITEA_DEFAULT_BRANCH', 'main') + }; +} -- 2.52.0 From 72a10dcb4b90a5f73f9b6c110ceddf6a375b65ad Mon Sep 17 00:00:00 2001 From: someone <2+someone@noreply.localhost> Date: Fri, 24 Apr 2026 23:44:59 +0000 Subject: [PATCH 08/13] Sync workflow/VikunjaClient.gs --- workflow/VikunjaClient.gs | 226 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 workflow/VikunjaClient.gs diff --git a/workflow/VikunjaClient.gs b/workflow/VikunjaClient.gs new file mode 100644 index 0000000..e5b8e17 --- /dev/null +++ b/workflow/VikunjaClient.gs @@ -0,0 +1,226 @@ +/** + * VikunjaClient - Low-level API wrapper for Vikunja Task Management + */ + +/** + * Helper to ensure the base URL always points to the Vikunja API root (/api/v1) + * @return {string} Normalized API base URL + */ +function getVikunjaApiBaseUrl_() { + const config = getVikunjaConfig(); + let baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash + + if (!baseUrl.endsWith('/api/v1')) { + baseUrl += '/api/v1'; + } + + return baseUrl; +} + +function vikunjaRequest_(method, path, payload = null) { + const apiBaseUrl = getVikunjaApiBaseUrl_(); + const normalizedPath = path.startsWith('/') ? path : '/' + path; + const url = `${apiBaseUrl}${normalizedPath}`; + + console.log(`Vikunja Request: [${method}] ${url}`); + + const config = getVikunjaConfig(); + if (!config.token) { + throw new Error('Vikunja Token is missing from configuration.'); + } + + const options = { + method: method, + headers: { + 'Authorization': `Bearer ${config.token}`, + 'Content-Type': 'application/json' + }, + muteHttpExceptions: true + }; + + console.log('Vikunja Auth Header Present:', !!options.headers.Authorization); + + if (payload) { + options.payload = JSON.stringify(payload); + } + + try { + const response = UrlFetchApp.fetch(url, options); + const code = response.getResponseCode(); + const rawBody = response.getContentText(); + + let json = null; + if (rawBody) { + try { + json = JSON.parse(rawBody); + } catch (e) { + console.warn(`Non-JSON response received from Vikunja: ${rawBody.substring(0, 200)}...`); + } + } + + if (code >= 200 && code < 300) { + return { + ok: true, + code: code, + data: json, + rawBody: rawBody, + message: 'Request successful' + }; + } + + return { + ok: false, + code: code, + data: json, + rawBody: rawBody, + message: json && json.message ? json.message : `Vikunja API Error ${code}` + }; + } catch (e) { + return { + ok: false, + code: 500, + data: null, + rawBody: null, + message: `Vikunja Connection Error: ${e.message}` + }; + } +} + +function getVikunjaTask(taskId) { + return vikunjaRequest_('GET', `/tasks/${taskId}`); +} + +function createVikunjaComment(taskId, commentText) { + const payload = { comment: commentText }; + const path = `/tasks/${taskId}/comments`; + + console.log(`Vikunja Comment Create Attempt: [PUT] ${path} | Payload: ${JSON.stringify(payload)}`); + + const res = vikunjaRequest_('PUT', path, payload); + + console.log(`Vikunja Comment Response: Code ${res.code} | Body: ${res.rawBody}`); + return res; +} + +function updateVikunjaTask(taskId, data) { + console.log(`Vikunja Task Update Attempt: [POST] /tasks/${taskId} | Payload: ${JSON.stringify(data)}`); + const res = vikunjaRequest_('POST', `/tasks/${taskId}`, data); + console.log(`Vikunja Task Update Response: Code ${res.code} | Body: ${res.rawBody}`); + return res; +} + +function setVikunjaTaskDone(taskId, isDone) { + return updateVikunjaTask(taskId, { done: isDone }); +} + +function createVikunjaTask(taskData) { + const projectId = taskData.projectId || taskData.project_id; + if (!projectId) { + return { ok: false, message: 'projectId is required to create a task in Vikunja.' }; + } + + // Construct full payload including priority and description + const payload = { + title: taskData.title, + description: taskData.description || '', + priority: typeof taskData.priority === 'number' ? taskData.priority : 0 + }; + + const endpoint = `/projects/${projectId}/tasks`; + console.log(`Vikunja Create Attempt: [PUT] ${endpoint} | Payload: ${JSON.stringify(payload)}`); + + // Using PUT as per instance-specific evidence (POST returns 405) + const res = vikunjaRequest_('PUT', endpoint, payload); + + console.log(`Vikunja Response: Code ${res.code} | Body: ${res.rawBody}`); + + if (res.ok && res.data && res.data.id) { + return { + ok: true, + code: res.code, + data: res.data, + rawBody: res.rawBody, + message: 'Task created successfully' + }; + } + + return { + ok: false, + code: res.code || 500, + data: res.data || null, + rawBody: res.rawBody || '', + message: `Task creation failed: ${res.message || 'Unknown error'}. Raw body: ${res.rawBody || 'No body'}` + }; +} + +function searchVikunjaTasks(query) { + // API v1 /tasks returns list of tasks. + // We fetch and filter client-side to avoid complex filter syntax errors. + const res = vikunjaRequest_('GET', '/tasks'); + if (!res.ok) return res; + + const filtered = (res.data || []).filter(t => + t.title && t.title.toLowerCase().includes(query.toLowerCase()) + ); + return { ok: true, data: filtered }; +} + +function getLabelIdByName_(labelName) { + const res = vikunjaRequest_('GET', '/labels'); + if (!res.ok) return null; + + // Vikunja labels typically use 'title' for the display name + const label = (res.data || []).find(l => + (l.title && l.title.toLowerCase() === labelName.toLowerCase()) || + (l.name && l.name.toLowerCase() === labelName.toLowerCase()) + ); + return label ? label.id : null; +} + +function createVikunjaLabel(labelName) { + // Using PUT as per instance evidence for label creation + const payload = { title: labelName }; + + console.log(`Vikunja Label Create Attempt: [PUT] /labels | Payload: ${JSON.stringify(payload)}`); + const res = vikunjaRequest_('PUT', '/labels', payload); + + if (res.ok && res.data && res.data.id) { + return { ok: true, id: res.data.id, code: res.code }; + } + + console.error(`Vikunja Label Create Failed: Code ${res.code} | Body: ${res.rawBody}`); + return { ok: false, message: res.message || 'Failed to create label', code: res.code }; +} + +function addVikunjaLabel(taskId, labelName) { + let labelId = getLabelIdByName_(labelName); + + // Auto-create label if it doesn't exist + if (!labelId) { + const createRes = createVikunjaLabel(labelName); + if (!createRes.ok) { + return { + ok: false, + message: `Label resolution failed: ${createRes.message}`, + code: createRes.code + }; + } + labelId = createRes.id; + } + + // Attach label to task using PUT as per instance evidence + const payload = { label_id: labelId }; + console.log(`Vikunja Label Attach Attempt: [PUT] /tasks/${taskId}/labels | Payload: ${JSON.stringify(payload)}`); + + const res = vikunjaRequest_('PUT', `/tasks/${taskId}/labels`, payload); + + if (!res.ok) { + console.error(`Vikunja Label Attach Failed: Code ${res.code} | Body: ${res.rawBody}`); + } + + return res; +} + +function deleteVikunjaTask(taskId) { + return vikunjaRequest_('DELETE', `/tasks/${taskId}`); +} -- 2.52.0 From 057d7926bea2db68dc8c0be35c016c9686c7721a Mon Sep 17 00:00:00 2001 From: someone <2+someone@noreply.localhost> Date: Fri, 24 Apr 2026 23:45:00 +0000 Subject: [PATCH 09/13] Sync workflow/GiteaClient.gs --- workflow/GiteaClient.gs | 106 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 workflow/GiteaClient.gs diff --git a/workflow/GiteaClient.gs b/workflow/GiteaClient.gs new file mode 100644 index 0000000..5734361 --- /dev/null +++ b/workflow/GiteaClient.gs @@ -0,0 +1,106 @@ +/** + * GiteaClient - Low-level API wrapper for Gitea Git Hosting + */ + +/** + * Helper to ensure the base URL always points to the Gitea API root (/api/v1) + * @return {string} Normalized API base URL + */ +function getGiteaApiBaseUrl_() { + const config = getGiteaConfig(); + let baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash + + // Ensure the URL ends with /api/v1 + if (!baseUrl.endsWith('/api/v1')) { + baseUrl += '/api/v1'; + } + + return baseUrl; +} + +function giteaRequest_(method, path, payload = null) { + const apiBaseUrl = getGiteaApiBaseUrl_(); + const config = getGiteaConfig(); + + // Ensure path starts with a single slash + const normalizedPath = path.startsWith('/') ? path : '/' + path; + const url = `${apiBaseUrl}${normalizedPath}`; + + console.log(`Gitea Request: [${method}] ${url}`); + + const options = { + method: method, + headers: { + 'Authorization': `token ${config.token}`, + 'Content-Type': 'application/json' + }, + muteHttpExceptions: true + }; + + if (payload) { + options.payload = JSON.stringify(payload); + } + + try { + const response = UrlFetchApp.fetch(url, options); + const code = response.getResponseCode(); + const rawContent = response.getContentText(); + + let json; + try { + json = JSON.parse(rawContent); + } catch (e) { + json = null; // Not a JSON response + } + + if (code >= 200 && code < 300) { + return { ok: true, data: json || rawContent }; + } + + // Return structured error with raw body if JSON parsing failed + return { + ok: false, + message: json && json.message ? json.message : `Gitea API Error ${code}`, + code: code, + rawBody: rawContent + }; + } catch (e) { + return { ok: false, message: `Gitea Connection Error: ${e.message}` }; + } +} + +function getGiteaRepo() { + const config = getGiteaConfig(); + // Path is relative to /api/v1 + return giteaRequest_('GET', `/repos/${config.owner}/${config.repo}`); +} + +function createGiteaIssue(title, body) { + const config = getGiteaConfig(); + return giteaRequest_('POST', `/repos/${config.owner}/${config.repo}/issues`, { + title: title, + body: body + }); +} + +function createOrUpdateRepoFile(path, content, message, branch) { + const config = getGiteaConfig(); + const targetBranch = branch || config.branch; + + // Gitea requires SHA for updates. This is a simplified boilerplate. + return giteaRequest_('POST', `/repos/${config.owner}/${config.repo}/contents/${path}`, { + content: content, + message: message, + branch: targetBranch + }); +} + +function createReleaseTag(tagName, name, body, target) { + const config = getGiteaConfig(); + return giteaRequest_('POST', `/repos/${config.owner}/${config.repo}/releases`, { + tag_name: tagName, + name: name, + body: body, + target_commitish: target || config.branch + }); +} -- 2.52.0 From 8228d17a305b257bda98633a4943522db40ef692 Mon Sep 17 00:00:00 2001 From: someone <2+someone@noreply.localhost> Date: Fri, 24 Apr 2026 23:45:02 +0000 Subject: [PATCH 10/13] Sync workflow/WorkflowHooks.gs --- workflow/WorkflowHooks.gs | 178 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 workflow/WorkflowHooks.gs diff --git a/workflow/WorkflowHooks.gs b/workflow/WorkflowHooks.gs new file mode 100644 index 0000000..19e29a1 --- /dev/null +++ b/workflow/WorkflowHooks.gs @@ -0,0 +1,178 @@ +/** + * WorkflowHooks - High-level orchestrator for business workflows. + * Core app files should call these functions instead of Clients directly. + */ + +function workflowStartFeature(featureCode, taskId) { + const note = `๐Ÿš€ Feature [${featureCode}] has been started in SchoolHub.`; + const res = createVikunjaComment(taskId, note); + + return res.ok ? { ok: true } : { ok: false, message: res.message }; +} + +function workflowMarkInternalTest(featureCode, taskId, note) { + const fullNote = `๐Ÿงช Internal Testing for [${featureCode}]: ${note}`; + const res = createVikunjaComment(taskId, fullNote); + + return res.ok ? { ok: true } : { ok: false, message: res.message }; +} + +function workflowMarkDeploySuccess(featureCode, taskId, deployInfo) { + // 1. Add deployment confirmation comment + const comment = `โœ… Deployed to Production: ${deployInfo || 'No additional info'}`; + createVikunjaComment(taskId, comment); + + // 2. Automatically mark task as done using the workflow helper + // We pass a minimal meta object to resolve the task + markWorkflowTaskDone_({ featureCode, title: 'Deployment' }, true); + + return { ok: true }; +} + +/** + * Private helper to mark a workflow task as done or reopened. + * Resolves the task ID via ensureWorkflowTask to ensure consistency. + */ +function markWorkflowTaskDone_(featureMeta, isDone) { + try { + const ensureRes = ensureWorkflowTask(featureMeta); + if (!ensureRes.ok || !ensureRes.taskId) { + console.warn(`Workflow Done Status Warning: Could not resolve task for ${featureMeta.featureCode}`); + return; + } + + const taskId = ensureRes.taskId; + const patch = { done: isDone }; + + const res = updateVikunjaTask(taskId, patch); + if (res.ok) { + console.log(`Workflow Done Status: Task ${taskId} marked as ${isDone ? 'DONE' : 'OPEN'}`); + } else { + console.warn(`Workflow Done Status Failure: ${res.message}`); + } + } catch (e) { + console.error(`Workflow Done Status Error: ${e.message}`); + } +} + +function workflowCreateRelease(featureCode, taskId, releaseInfo) { + const { tagName, name, body } = releaseInfo; + + // 1. Create Release in Gitea + const gRes = createReleaseTag(tagName, name, body, null); + + if (!gRes.ok) return { ok: false, message: `Gitea Release Failed: ${gRes.message}` }; + + // 2. Link Gitea release back to Vikunja task + const config = getGiteaConfig(); + const releaseUrl = `${config.baseUrl}/repos/${config.owner}/${config.repo}/releases/tags/${tagName}`; + const vRes = createVikunjaComment(taskId, `๐Ÿ“ฆ Release created: ${releaseUrl}`); + + return vRes.ok ? { ok: true } : { ok: false, message: vRes.message }; +} + +/** + * Ensures a Vikunja task exists for a specific feature/bug. + * If it exists, returns it. If not, creates it with calculated priority and labels. + * + * @param {Object} featureMeta - { featureCode, title, type, module, impactArea, severity, requestedBy, source } + */ +function ensureWorkflowTask(featureMeta) { + try { + const { featureCode, title } = featureMeta; + if (!featureCode || !title) throw new Error('featureCode and title are required for task assurance.'); + + // 1. Try to find existing task + const existingTask = findExistingWorkflowTask_(featureMeta); + if (existingTask) { + return { + ok: true, + taskId: existingTask.id, + state: 'EXISTING', + priority: existingTask.priority, + labels: existingTask.labels || [] + }; + } + + // 2. Derive Priority and Labels + const priority = deriveWorkflowPriority_(featureMeta); + const labels = deriveWorkflowLabels_(featureMeta); + + // 3. Create Task + const config = getVikunjaConfig(); + const taskPayload = { + title: `[${featureCode}] ${title}`, + description: `Source: ${featureMeta.source || 'System'}\nRequested by: ${featureMeta.requestedBy || 'Unknown'}\nModule: ${featureMeta.module || 'General'}`, + projectId: config.projectId, + priority: priority + }; + + const createRes = createVikunjaTask(taskPayload); + if (!createRes.ok) throw new Error(`Task creation failed: ${createRes.message}`); + + const taskId = createRes.data.id; + + // 4. Attach Labels + attachLabelsToTask_(taskId, labels); + + return { + ok: true, + taskId: taskId, + state: 'CREATED', + priority: priority, + labels: labels + }; + + } catch (e) { + console.error(`Workflow Task Assurance Error: ${e.message}`); + return { ok: false, message: e.message }; + } +} + +function findExistingWorkflowTask_(featureMeta) { + const searchRes = searchVikunjaTasks(featureMeta.featureCode); + if (searchRes.ok && Array.isArray(searchRes.data)) { + // Find exact match of [CODE] in title + return searchRes.data.find(t => t.title.includes(`[${featureMeta.featureCode}]`)); + } + return null; +} + +function deriveWorkflowPriority_(featureMeta) { + const { type, module, severity } = featureMeta; + const highPrioKeywords = ['auth', 'login', 'otp', 'admin', 'rbac', 'security', 'payment']; + const modValue = (module || '').toLowerCase(); + + // Rule 1: Critical severity OR critical modules = Priority 5 (Highest) + const isCriticalModule = highPrioKeywords.some(kw => modValue.includes(kw)); + if (severity === 'critical' || isCriticalModule) return 5; + + // Rule 2: Bugfixes OR High severity = Priority 4 + if (type === 'bugfix' || severity === 'high') return 4; + + // Rule 3: Features OR Improvements = Priority 3 + if (type === 'feature' || type === 'improvement') return 3; + + // Rule 4: Refactors OR Docs OR Low severity = Priority 2 + if (type === 'refactor' || type === 'docs' || severity === 'low') return 2; + + // Rule 5: Default = Priority 1 (Lowest) + return 1; +} + +function deriveWorkflowLabels_(featureMeta) { + const labels = []; + if (featureMeta.type) labels.push(`type:${featureMeta.type}`); + if (featureMeta.module) labels.push(`mod:${featureMeta.module}`); + if (featureMeta.impactArea) labels.push(`impact:${featureMeta.impactArea}`); + if (featureMeta.severity) labels.push(`sev:${featureMeta.severity}`); + + return labels; +} + +function attachLabelsToTask_(taskId, labels) { + labels.forEach(label => { + const res = addVikunjaLabel(taskId, label); + if (!res.ok) console.warn(`Workflow Label Warning: ${res.message}`); + }); +} -- 2.52.0 From 9ef20a7667328177268e1dd3230745c1eaa2cc58 Mon Sep 17 00:00:00 2001 From: someone <2+someone@noreply.localhost> Date: Fri, 24 Apr 2026 23:45:04 +0000 Subject: [PATCH 11/13] Sync workflow/WorkflowTest.gs --- workflow/WorkflowTest.gs | 761 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 761 insertions(+) create mode 100644 workflow/WorkflowTest.gs diff --git a/workflow/WorkflowTest.gs b/workflow/WorkflowTest.gs new file mode 100644 index 0000000..b696845 --- /dev/null +++ b/workflow/WorkflowTest.gs @@ -0,0 +1,761 @@ +/** + * ๐Ÿงช WORKFLOW INTEGRATION TESTS + * ============================================================================= + * This file contains manual test helpers to verify the connection between + * SchoolHub and external workflow tools (Vikunja & Gitea). + * + * INSTRUCTIONS: + * 1. Select the desired test function from the toolbar dropdown. + * 2. Click "Run". + * 3. Check the "Execution Log" for the results. + * ============================================================================= + */ + +/** + * Test 1: Verify Script Properties are readable. + * Goal: Ensure VIKUNJA_BASE_URL, VIKUNJA_TOKEN, etc., are set. + */ +function testWorkflowConfig() { + console.log('--- Starting Test: Workflow Config ---'); + try { + const vikunja = getVikunjaConfig(); + const gitea = getGiteaConfig(); + + const results = { + vikunjaUrl: !!vikunja.baseUrl, + vikunjaToken: !!vikunja.token, + giteaUrl: !!gitea.baseUrl, + giteaToken: !!gitea.token, + giteaOwner: !!gitea.owner, + giteaRepo: !!gitea.repo + }; + + const allPassed = Object.values(results).every(val => val === true); + + if (allPassed) { + console.log('โœ… PASS: All required Script Properties are present.'); + } else { + console.error('โŒ FAIL: Some properties are missing:'); + console.log(JSON.stringify(results, null, 2)); + } + return { ok: allPassed, data: results }; + } catch (e) { + console.error('โŒ ERROR: ' + e.message); + return { ok: false, message: e.message }; + } +} + +/** + * Test 2: Verify Gitea Connectivity. + * Goal: Perform a read-only request to the repository root. + */ +function testGiteaConnection() { + console.log('--- Starting Test: Gitea Connection ---'); + const res = getGiteaRepo(); + + if (res.ok) { + console.log('โœ… PASS: Gitea authenticated successfully.'); + console.log('Repo Name: ' + res.data.name); + return { ok: true, data: res.data }; + } else { + console.error('โŒ FAIL: Gitea connection failed: ' + res.message); + return { ok: false, message: res.message }; + } +} + +/** + * Test 3: Verify Vikunja Connectivity using a Task ID. + * @param {string} taskId - Replace with a valid Task ID from your Vikunja instance. + */ +function testVikunjaRead() { + const taskId = 'REPLACE_WITH_ACTUAL_TASK_ID'; + if (taskId === 'REPLACE_WITH_ACTUAL_TASK_ID') { + console.warn('โš ๏ธ SKIP: Please provide a valid taskId in the code first.'); + return { ok: false, message: 'No Task ID provided' }; + } + console.log(`--- Starting Test: Vikunja Read (Task: ${taskId}) ---`); + const res = getVikunjaTask(taskId); + + if (res.ok) { + console.log('โœ… PASS: Vikunja authenticated and task retrieved.'); + console.log('Task Title: ' + (res.data.title || 'No Title')); + return { ok: true, data: res.data }; + } else { + console.error('โŒ FAIL: Vikunja read failed: ' + res.message); + if (res.rawBody) console.log('Raw Response: ' + res.rawBody); + return { ok: false, message: res.message }; + } +} + +/** + * Test 4: Verify Vikunja Write Operation. + * Goal: Add a clearly marked test comment. + * @param {string} taskId - Replace with a valid Task ID. + */ +function testVikunjaComment() { + const taskId = 'REPLACE_WITH_ACTUAL_TASK_ID'; + + if (taskId === 'REPLACE_WITH_ACTUAL_TASK_ID') { + console.warn('โš ๏ธ SKIP: Please provide a valid taskId in the code first.'); + return { ok: false, message: 'No Task ID provided' }; + } + console.log(`--- Starting Test: Vikunja Write (Task: ${taskId}) ---`); + const testMessage = `[TEST] Integration check from SchoolHub at ${new Date().toISOString()}`; + const res = createVikunjaComment(taskId, testMessage); + + if (res.ok) { + console.log('โœ… PASS: Test comment successfully created.'); + return { ok: true }; + } else { + console.error('โŒ FAIL: Vikunja write failed: ' + res.message); + return { ok: false, message: res.message }; + } +} +/* + Test 6: Automated Vikunja Lifecycle Test + Goal: Create -> Read -> Comment -> Hook -> Delete (in Test Project) + */ +function testVikunjaLifecycle() { + console.log('--- Starting Test: Vikunja Lifecycle (Auto) ---'); + + const config = getTestProjectConfig(); + const testProjectId = config.testProjectId; + + if (!testProjectId) { + console.warn('โš ๏ธ SKIP: VIKUNJA_TEST_PROJECT_ID not found in Script Properties.'); + return { ok: false, message: 'Missing test project ID' }; + } + + let createdTaskId = null; + const steps = []; + let cleanedUp = false; + + try { + console.log('Step 1: Creating temporary task...'); + const createData = { + title: `[TEST][AUTO][SchoolHub] Lifecycle Test ${new Date().getTime()}`, + projectId: testProjectId + }; + + const createRes = createVikunjaTask(createData); + if (!createRes.ok || !createRes.data || !createRes.data.id) { + throw new Error(`Task Creation Failed: ${createRes.message || 'No ID returned'}. Raw: ${createRes.rawBody || 'N/A'}`); + } + + createdTaskId = createRes.data.id; + steps.push('TASK_CREATED'); + console.log(`โœ… Task created with ID: ${createdTaskId}`); + + console.log('Step 2: Reading task back...'); + const readRes = getVikunjaTask(createdTaskId); + if (!readRes.ok) throw new Error(`Read failed: ${readRes.message}`); + + steps.push('TASK_READ'); + console.log(`โœ… Task verified: ${readRes.data.title}`); + + console.log('Step 3: Adding test comment...'); + const commentRes = createVikunjaComment(createdTaskId, '[TEST] Automated lifecycle comment check.'); + if (!commentRes.ok) { + throw new Error(`Comment failed (Code: ${commentRes.code}): ${commentRes.message}. Raw: ${commentRes.rawBody}`); + } + + steps.push('COMMENT_ADDED'); + console.log('โœ… Comment added successfully'); + + console.log('Step 4: Running workflow hook...'); + const hookRes = workflowStartFeature('TEST-AUTO', createdTaskId); + if (!hookRes.ok) throw new Error(`Hook failed: ${hookRes.message}`); + + steps.push('HOOK_EXECUTED'); + console.log('โœ… Workflow hook executed successfully'); + + return { + ok: true, + createdTaskId, + cleanedUp: cleanedUp, + steps, + message: 'Full lifecycle completed successfully' + }; + } catch (e) { + console.error(`โŒ Lifecycle Test Failed at step ${steps.length + 1}: ${e.message}`); + return { + ok: false, + createdTaskId, + cleanedUp: cleanedUp, + steps, + message: e.message + }; + } finally { + if (createdTaskId) { + console.log('Final Step: Cleaning up temporary task...'); + const delRes = deleteVikunjaTask(createdTaskId); + + if (delRes.ok) { + console.log('โœ… Temporary task deleted.'); + cleanedUp = true; + } else { + console.error(`โŒ Cleanup failed for task ${createdTaskId}: ${delRes.message}`); + cleanedUp = false; + } + } + } +} + +function testMarkWorkflowTaskDone() { + console.log('--- Starting Test: markWorkflowTaskDone ---'); + + const featureMeta = { + featureCode: `DONE-TEST-${Date.now()}`, + title: 'Automated Done Status Test', + type: 'feature', + module: 'general' + }; + + try { + // 1. Ensure task exists + const ensureRes = ensureWorkflowTask(featureMeta); + const taskId = ensureRes.taskId; + console.log(`Step 1: Task created/resolved: ${taskId}`); + + // 2. Mark as done + markWorkflowTaskDone_(featureMeta, true); + console.log('Step 2: markWorkflowTaskDone_ executed'); + + // 3. Verify status + const taskRes = getVikunjaTask(taskId); + if (!taskRes.ok) throw new Error(`Read failed: ${taskRes.message}`); + + const task = taskRes.data; + assertEqual_('Task is marked done', task.done, true); + + // done_at is optional depending on server-side trigger/version + if (task.done_at) { + assertTrue_('Task has done_at timestamp', !!task.done_at); + } else { + console.log('โ„น๏ธ Note: Server did not return done_at, but task.done is true.'); + } + + console.log('--- Test Passed: markWorkflowTaskDone ---'); + + // Cleanup + deleteVikunjaTask(taskId); + } catch (e) { + console.error(`โŒ Test Failed: ${e.message}`); + throw e; + } +} + +function testVikunjaCreateOnly() { + const config = getTestProjectConfig(); + if (!config.testProjectId) return { ok: false, message: 'No test project ID' }; + + const res = createVikunjaTask({ + title: `[DEBUG] Create Test ${new Date().getTime()}`, + projectId: config.testProjectId, + priority: 4 + }); + + console.log(res.ok ? `โœ… Created: ${res.data && res.data.id}` : `โŒ Failed: ${res.message}`); + return res; +} + +/** + * ASSERTION HELPERS + */ +function assertEqual_(name, actual, expected) { + if (actual !== expected) { + throw new Error(`[ASSERT FAILED] ${name}: Expected ${expected}, but got ${actual}`); + } + console.log(`โœ… ${name}: ${actual} == ${expected}`); +} + +function assertArrayEqual_(name, actual, expected) { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) { + throw new Error(`[ASSERT FAILED] ${name}: Expected ${e}, but got ${a}`); + } + console.log(`โœ… ${name}: ${a} == ${e}`); +} + +function assertTrue_(name, condition) { + if (!condition) { + throw new Error(`[ASSERT FAILED] ${name}: Expected true, but got false`); + } + console.log(`โœ… ${name}: true`); +} + +/** + * Test: Workflow Priority Rules + * Goal: Validate that the priority mapping (5..1) follows business rules. + */ +function testWorkflowPriorityRules() { + console.log('--- Starting Test: Workflow Priority Rules ---'); + + const testCases = [ + { + name: "Critical severity overrides all", + input: { type: 'feature', module: 'general', severity: 'critical' }, + expected: 5 + }, + { + name: "Auth module gets highest priority", + input: { type: 'feature', module: 'auth', severity: 'medium' }, + expected: 5 + }, + { + name: "Bugfix gets priority 4", + input: { type: 'bugfix', module: 'student', severity: 'medium' }, + expected: 4 + }, + { + name: "High severity gets priority 4", + input: { type: 'feature', module: 'student', severity: 'high' }, + expected: 4 + }, + { + name: "Feature gets priority 3", + input: { type: 'feature', module: 'student', severity: 'medium' }, + expected: 3 + }, + { + name: "Improvement gets priority 3", + input: { type: 'improvement', module: 'teacher', severity: 'medium' }, + expected: 3 + }, + { + name: "Refactor gets priority 2", + input: { type: 'refactor', module: 'general', severity: 'medium' }, + expected: 2 + }, + { + name: "Docs gets priority 2", + input: { type: 'docs', module: 'general', severity: 'medium' }, + expected: 2 + }, + { + name: "Low severity gets priority 2", + input: { type: 'chore', module: 'general', severity: 'low' }, + expected: 2 + }, + { + name: "Fallback gets priority 1", + input: { type: 'chore', module: 'general', severity: 'medium' }, + expected: 1 + }, + ]; + + testCases.forEach(tc => { + const actual = deriveWorkflowPriority_(tc.input); + assertEqual_(tc.name, actual, tc.expected); + }); + + console.log('--- Test Passed: Workflow Priority Rules ---'); +} + +/** + * Test: Workflow Label Rules + * Goal: Validate that labels are correctly derived from metadata. + */ +function testWorkflowLabelRules() { + console.log('--- Starting Test: Workflow Label Rules ---'); + + const fullInput = { + type: 'bugfix', + module: 'auth', + impactArea: 'student', + severity: 'high' + }; + const expectedFull = ['type:bugfix', 'mod:auth', 'impact:student', 'sev:high']; + assertArrayEqual_('Full metadata labels', deriveWorkflowLabels_(fullInput), expectedFull); + + const minimalInput = { type: 'feature' }; + const expectedMinimal = ['type:feature']; + assertArrayEqual_('Minimal metadata labels', deriveWorkflowLabels_(minimalInput), expectedMinimal); + + console.log('--- Test Passed: Workflow Label Rules ---'); +} + +/** + * Test: Ensure Workflow Task Integration + * Goal: Verify the full cycle: ensure task -> verify in Vikunja -> delete. + */ +function testEnsureWorkflowTask() { + console.log('--- Starting Test: Ensure Workflow Task ---'); + + const featureMeta = { + featureCode: `TEST-AUTO-PRIO-${Date.now()}`, + title: 'Automated ensureWorkflowTask priority and label check', + type: 'bugfix', + module: 'auth', + impactArea: 'student', + severity: 'critical', + requestedBy: 'Automated Test', + source: 'WorkflowTest' + }; + + let createdTaskId = null; + + try { + const ensureRes = ensureWorkflowTask(featureMeta); + console.log('Ensure Result: ' + JSON.stringify(ensureRes)); + + assertTrue_('Ensure task ok', ensureRes.ok); + assertTrue_('Task ID exists', !!ensureRes.taskId); + assertTrue_('Task state is valid', ['CREATED', 'EXISTING'].includes(ensureRes.state)); + + createdTaskId = ensureRes.taskId; + + const taskRes = getVikunjaTask(createdTaskId); + assertTrue_('Task fetch ok', taskRes.ok); + assertTrue_('Task data exists', !!taskRes.data); + + const task = taskRes.data; + assertTrue_('Title contains feature code', task.title.includes(`[${featureMeta.featureCode}]`)); + assertEqual_('Task priority matches rule', task.priority, deriveWorkflowPriority_(featureMeta)); + + const expectedLabels = deriveWorkflowLabels_(featureMeta); + if (Array.isArray(task.labels)) { + const actualLabels = task.labels.map(l => l.title || l.name); + expectedLabels.forEach(el => { + assertTrue_(`Label ${el} present`, actualLabels.includes(el)); + }); + } else { + console.log('โ„น๏ธ Labels not returned as array; verification skipped for this instance.'); + } + + console.log('--- Test Passed: Ensure Workflow Task ---'); + } catch (e) { + console.error(`โŒ Test Failed: ${e.message}`); + throw e; + } finally { + if (createdTaskId) { + const delRes = deleteVikunjaTask(createdTaskId); + if (delRes.ok) console.log('โœ… Cleanup: Temporary task deleted.'); + else console.warn('โš ๏ธ Cleanup: Failed to delete temporary task.'); + } + } +} + +/** + * Test 5: Workflow Hook Dry Run. + * Goal: Verify the orchestrator can call the client. + * @param {string} taskId - Replace with a valid Task ID. + */ +function testWorkflowHooksDryRun() { + const taskId = 'REPLACE_WITH_ACTUAL_TASK_ID'; + + if (taskId === 'REPLACE_WITH_ACTUAL_TASK_ID') { + console.warn('โš ๏ธ SKIP: Please provide a valid taskId in the code first.'); + return { ok: false, message: 'No Task ID provided' }; + } + + console.log(`--- Starting Test: Workflow Hook Dry Run (Task: ${taskId}) ---`); + const res = workflowStartFeature('TEST-001', taskId); + + if (res.ok) { + console.log('โœ… PASS: workflowStartFeature executed successfully.'); + return { ok: true }; + } else { + console.error(`โŒ FAIL: Workflow hook failed: ${res.message}`); + return { ok: false, message: res.message }; + } +} + +/* + * ๐Ÿงช GITEA SYNC INTEGRATION TESTS + * ============================================================================= + */ + +function testGiteaPathMapping() { + console.log('--- Starting Test: Gitea Path Mapping ---'); + + const testCases = [ + { input: { name: 'appsscript', type: 'JSON' }, expected: 'appsscript.json' }, + { input: { name: 'Code', type: 'SERVER_JS' }, expected: 'app/Code.gs' }, + { input: { name: 'Index', type: 'HTML' }, expected: 'app/Index.html' }, + { input: { name: 'styles', type: 'HTML' }, expected: 'app/styles.html' }, + { input: { name: 'Readme', type: 'SERVER_JS' }, expected: 'docs/Readme.gs' }, + { input: { name: 'WorkflowConfig', type: 'SERVER_JS' }, expected: 'workflow/WorkflowConfig.gs' }, + { input: { name: 'Unknown', type: 'OTHER' }, expected: null } + ]; + + testCases.forEach(function(tc) { + const actual = mapAppsScriptFileToRepoPath(tc.input); + assertEqual_( + `Path mapping for ${tc.input.name} (${tc.input.type})`, + actual, + tc.expected + ); + }); + + console.log('--- Test Passed: Gitea Path Mapping ---'); +} + +function testGiteaBranchNaming() { + console.log('--- Starting Test: Gitea Branch Naming ---'); + + const testCases = [ + { code: 'feat/login', expectedBase: 'feat-login' }, + { code: 'BUG #123', expectedBase: 'bug-123' }, + { code: '---cool---feature---', expectedBase: 'cool-feature' }, + { code: null, expectedBase: 'manual' }, + { code: '', expectedBase: 'manual' } + ]; + + testCases.forEach(function(tc) { + const branch = buildSyncBranchName(tc.code); + + assertTrue_( + `Branch starts with sync/ for input: ${tc.code}`, + branch.indexOf('sync/') === 0 + ); + + assertTrue_( + `Branch contains sanitized base "${tc.expectedBase}"`, + branch.indexOf(tc.expectedBase) !== -1 + ); + + assertTrue_( + `Branch ends with timestamp pattern for input: ${tc.code}`, + /\d{8}-\d{4}$/.test(branch) + ); + + assertTrue_( + `Branch has no duplicate hyphens for input: ${tc.code}`, + !/--/.test(branch) + ); + }); + + console.log('--- Test Passed: Gitea Branch Naming ---'); +} +/** + * Convenience runner for the Workflow Rules Suite. + * Runs priority, label, and task assurance tests. + */ +function testWorkflowRulesSuite() { + console.log('=== Starting Workflow Rules Suite ==='); + + try { + testWorkflowPriorityRules(); + testWorkflowLabelRules(); + testEnsureWorkflowTask(); + console.log('=== Workflow Rules Suite Passed ==='); + } catch (e) { + console.error('=== Workflow Rules Suite FAILED ==='); + console.error(e.message); + throw e; + } +} + +function testPrepareGitSyncPayload() { + console.log('--- Starting Test: Prepare Git Sync Payload ---'); + + const mockFiles = [ + { name: 'appsscript', type: 'JSON', source: '{ "timeZone": "UTC" }' }, + { name: 'Code', type: 'SERVER_JS', source: 'function test() {}' }, + { name: 'Index', type: 'HTML', source: '' }, + { name: 'InvalidFile', type: 'UNKNOWN', source: 'junk' }, + { name: 'MissingSource', type: 'SERVER_JS' }, + null + ]; + + const payload = prepareGitSyncPayload('test-feature', mockFiles); + + assertTrue_('Base branch is set', !!payload.baseBranch); + assertTrue_('Sync branch starts with sync/', payload.syncBranch.indexOf('sync/') === 0); + assertTrue_('Sync branch contains feature code', payload.syncBranch.indexOf('test-feature') !== -1); + + assertEqual_('Valid files mapped correctly', payload.files.length, 4); + assertEqual_('JSON mapped to appsscript.json', payload.files[0].repoPath, 'appsscript.json'); + assertEqual_('SERVER_JS mapped to app/Code.gs', payload.files[1].repoPath, 'app/Code.gs'); + assertEqual_('HTML mapped to app/Index.html', payload.files[2].repoPath, 'app/Index.html'); + assertEqual_('SERVER_JS missing source mapped to workflow/MissingSource.gs', payload.files[3].repoPath, 'workflow/MissingSource.gs'); + + console.log('--- Test Passed: Prepare Git Sync Payload ---'); +} + + +function testGiteaSyncHelpers() { + console.log('--- Starting Test: Gitea Sync Helpers ---'); + + const url = getGiteaSyncApiUrl('/test/path'); + assertTrue_('URL ends with /api/v1/test/path', url.indexOf('/api/v1/test/path') !== -1); + + const opts = getGiteaSyncRequestOptions('POST', { key: 'value' }); + assertEqual_('Method is POST', opts.method, 'POST'); + assertTrue_('Has Authorization header', !!opts.headers['Authorization']); + assertEqual_('contentType is application/json', opts.contentType, 'application/json'); + assertEqual_('Payload is stringified', opts.payload, '{"key":"value"}'); + + console.log('--- Test Passed: Gitea Sync Helpers ---'); +} + +function testGiteaBranchCreationPayload() { + console.log('--- Starting Test: Gitea Branch Creation Payload ---'); + + const baseBranch = 'main'; + const newBranch = 'sync/feat-test'; + const payload = { + new_branch_name: newBranch, + old_ref_name: baseBranch + }; + + assertEqual_('Payload uses old_ref_name instead of old_branch_name', payload.old_ref_name, 'main'); + assertTrue_('old_branch_name is undefined', payload.old_branch_name === undefined); + + console.log('--- Test Passed: Gitea Branch Creation Payload ---'); +} + +function testSyncSummaryFormat() { + console.log('--- Starting Test: Sync Summary Format ---'); + + const mockResults = { + created: 2, + updated: 1, + errors: 0, + details: [ + { file: "app/Code.gs", ok: true, message: "" }, + { file: "appsscript.json", ok: true, message: "" }, + { file: "sync-manifest.json", ok: true, message: "" } + ] + }; + + assertTrue_('Summary has created count', mockResults.created === 2); + assertTrue_('Summary has updated count', mockResults.updated === 1); + assertTrue_('Summary has errors count', mockResults.errors === 0); + assertEqual_('Summary details length is correct', mockResults.details.length, 3); + + const mockFinalResult = { + ok: true, + branch: 'sync/manual', + summary: mockResults, + pullRequest: { url: 'https://gitea.example.com/pr/1', existed: true } + }; + + assertTrue_('Final result has branch info', !!mockFinalResult.branch); + assertTrue_('Final result has pullRequest info', !!mockFinalResult.pullRequest); + assertTrue_('Final result pullRequest has url', !!mockFinalResult.pullRequest.url); + assertEqual_('Final result pullRequest existed flag is true', mockFinalResult.pullRequest.existed, true); + + console.log('--- Test Passed: Sync Summary Format ---'); +} + + +function testGetAppsScriptProjectContentErrorFormat() { + console.log('--- Starting Test: Get Apps Script Project Content Error Format ---'); + + const mockErrorCode = 500; + const result = { ok: false, message: "Failed to fetch project content. Code: " + mockErrorCode }; + + assertEqual_('Result ok is false', result.ok, false); + assertEqual_('Message formatting correct', result.message, "Failed to fetch project content. Code: 500"); + + const result403 = { + ok: false, + message: "HTTP 403 Forbidden. Ensure Google Apps Script API is enabled in user settings (https://script.google.com/home/usersettings) and OAuth scopes are correctly configured.", + code: 403, + rawBody: "Mock raw body content" + }; + + assertEqual_('403 Result ok is false', result403.ok, false); + assertEqual_('403 Result code is 403', result403.code, 403); + assertTrue_('Message contains HTTP 403 Forbidden', result403.message.indexOf("HTTP 403 Forbidden") !== -1); + assertEqual_('rawBody is preserved', result403.rawBody, "Mock raw body content"); + + console.log('--- Test Passed: Get Apps Script Project Content Error Format ---'); +} + +function testScriptIdFallback() { + console.log('--- Starting Test: Script ID Fallback ---'); + + const currentScriptId = ScriptApp.getScriptId(); + const config = getSyncConfig(); + + assertTrue_('getSyncConfig provides a scriptId', !!config.scriptId); + console.log('Current ScriptApp ID: ' + currentScriptId); + console.log('Config Script ID: ' + config.scriptId); + + if (config.scriptId === currentScriptId) { + console.log('โœ… Fallback working or explicitly matched.'); + } else { + console.log('โ„น๏ธ Configured scriptId is different from current ScriptApp ID.'); + } + + console.log('--- Test Passed: Script ID Fallback ---'); +} + +function testGiteaPullRequestPayload() { + console.log('--- Starting Test: Gitea Pull Request Payload ---'); + + const payload = { + title: 'Sync Feature: test-feat', + body: 'Auto sync', + base: 'main', + head: 'sync/test-feat' + }; + + assertEqual_('PR title is set', payload.title, 'Sync Feature: test-feat'); + assertEqual_('PR base is main', payload.base, 'main'); + assertEqual_('PR head is correct', payload.head, 'sync/test-feat'); + + console.log('--- Test Passed: Gitea Pull Request Payload ---'); +} + +function testCheckGiteaPullRequestExistsLogic() { + console.log('--- Starting Test: Check Gitea PR Exists Logic ---'); + + const mockPrs = [ + { base: { ref: 'main' }, head: { ref: 'sync/feat-1' } }, + { base: { ref: 'main' }, head: { ref: 'sync/feat-2' } } + ]; + + const baseBranch = 'main'; + const headBranch = 'sync/feat-2'; + + const existing = mockPrs.find(function(pr) { + return pr.base && pr.head && pr.base.ref === baseBranch && pr.head.ref === headBranch; + }); + + assertTrue_('Finds existing PR', !!existing); + assertEqual_('Matches correct head', existing.head.ref, 'sync/feat-2'); + + const notExisting = mockPrs.find(function(pr) { + return pr.base && pr.head && pr.base.ref === baseBranch && pr.head.ref === 'sync/feat-3'; + }); + + assertTrue_('Does not find missing PR', !notExisting); + + console.log('--- Test Passed: Check Gitea PR Exists Logic ---'); +} + +function testEmptyRepoErrorDetection() { + console.log('--- Starting Test: Empty Repo Error Detection ---'); + + const mockCreateRes = { + ok: false, + message: "Branch creation failed: Git Repository is empty.", + code: 409 + }; + + const isRepoEmptyError = !mockCreateRes.ok && mockCreateRes.message && mockCreateRes.message.toLowerCase().indexOf('empty') !== -1; + assertTrue_('Detects empty repository error message', isRepoEmptyError); + + const mockSuccessRes = { + ok: true, + data: { name: "sync/feat-test" } + }; + + const isSuccessEmptyError = !mockSuccessRes.ok && mockSuccessRes.message && mockSuccessRes.message.toLowerCase().indexOf('empty') !== -1; + assertEqual_('Does not falsely detect empty repo on success', isSuccessEmptyError, false); + + const mockBootstrapFail = { + ok: false, + message: 'Git Repository is empty and bootstrap failed: 404 Not Found. Harap inisialisasi repositori secara manual (misal: centang "Initialize Repository" saat membuat repo).' + }; + const detectsManualInit = !mockBootstrapFail.ok && mockBootstrapFail.message.toLowerCase().indexOf('manual') !== -1; + assertTrue_('Detects manual initialization instruction on bootstrap failure', detectsManualInit); + + console.log('--- Test Passed: Empty Repo Error Detection ---'); +} + -- 2.52.0 From 7d8606daefceafd127eb8733b36875faddbba5ad Mon Sep 17 00:00:00 2001 From: someone <2+someone@noreply.localhost> Date: Fri, 24 Apr 2026 23:45:06 +0000 Subject: [PATCH 12/13] Sync workflow/GiteaSync.gs --- workflow/GiteaSync.gs | 402 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 workflow/GiteaSync.gs diff --git a/workflow/GiteaSync.gs b/workflow/GiteaSync.gs new file mode 100644 index 0000000..528dfc9 --- /dev/null +++ b/workflow/GiteaSync.gs @@ -0,0 +1,402 @@ +function getSyncConfig() { + const p = PropertiesService.getScriptProperties().getProperties(); + return { + baseUrl: p.GITEA_BASE_URL || '', + owner: p.GITEA_OWNER || '', + repo: p.GITEA_REPO || '', + token: p.GITEA_TOKEN || '', + defaultBranch: p.GITEA_DEFAULT_BRANCH || 'main', + scriptId: p.SCRIPT_ID || ScriptApp.getScriptId() + }; +} + +function formatSyncTimestamp() { + return Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyyMMdd-HHmm"); +} + +function encodeBase64Utf8(text) { + return Utilities.base64Encode(text || '', Utilities.Charset.UTF_8); +} + +function buildSyncBranchName(featureCode) { + var safeFeature = String(featureCode || "manual") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return "sync/" + (safeFeature || "manual") + "-" + formatSyncTimestamp(); +} + +function mapAppsScriptFileToRepoPath(file) { + if (!file || !file.name || !file.type) return null; + + if (file.name === 'appsscript' && file.type === 'JSON') { + return 'appsscript.json'; + } + + const appFiles = ['Code', 'Index', 'styles', 'scripts']; + const docsFiles = ['Readme']; + + let prefix = 'workflow/'; + if (appFiles.includes(file.name)) { + prefix = 'app/'; + } else if (docsFiles.includes(file.name)) { + prefix = 'docs/'; + } + + if (file.type === 'SERVER_JS') { + return prefix + file.name + '.gs'; + } + if (file.type === 'HTML') { + return prefix + file.name + '.html'; + } + + Logger.log('Unknown file type for mapping: ' + file.name + ' (' + file.type + ')'); + return null; +} + +function prepareGitSyncPayload(featureCode, appScriptFiles) { + const config = getSyncConfig(); + const syncBranch = buildSyncBranchName(featureCode); + const mappedFiles = []; + + if (appScriptFiles && Array.isArray(appScriptFiles)) { + appScriptFiles.forEach(function(file) { + const repoPath = mapAppsScriptFileToRepoPath(file); + if (repoPath) { + mappedFiles.push({ + repoPath: repoPath, + content: file.source || '', + originalName: file.name, + type: file.type + }); + } + }); + } + + return { + baseBranch: config.defaultBranch, + syncBranch: syncBranch, + files: mappedFiles + }; +} + +function getGiteaSyncApiUrl(path) { + const config = getSyncConfig(); + let baseUrl = config.baseUrl.replace(/\/$/, ''); + if (!baseUrl.endsWith('/api/v1')) { + baseUrl += '/api/v1'; + } + const normalizedPath = path.startsWith('/') ? path : '/' + path; + return baseUrl + normalizedPath; +} + +function getGiteaSyncRequestOptions(method, payload) { + const config = getSyncConfig(); + const options = { + method: method, + headers: { + 'Authorization': 'token ' + config.token + }, + contentType: 'application/json', + muteHttpExceptions: true + }; + if (payload) { + options.payload = JSON.stringify(payload); + } + return options; +} + +function giteaSyncRequest(method, path, payload) { + const url = getGiteaSyncApiUrl(path); + const options = getGiteaSyncRequestOptions(method, payload); + try { + const response = UrlFetchApp.fetch(url, options); + const code = response.getResponseCode(); + const rawContent = response.getContentText(); + let json = null; + try { + json = JSON.parse(rawContent); + } catch (e) {} + + if (code >= 200 && code < 300) { + return { ok: true, data: json || rawContent, code: code }; + } + return { + ok: false, + message: json && json.message ? json.message : 'API Error ' + code, + code: code, + rawBody: rawContent + }; + } catch (e) { + return { ok: false, message: 'Connection Error: ' + e.message, code: 500 }; + } +} + +function createGiteaSyncBranch(baseBranch, newBranch) { + const config = getSyncConfig(); + const path = '/repos/' + config.owner + '/' + config.repo + '/branches'; + const payload = { + new_branch_name: newBranch, + old_ref_name: baseBranch || config.defaultBranch + }; + return giteaSyncRequest('POST', path, payload); +} + +function getGiteaFileMetadata(repoPath, branch) { + const config = getSyncConfig(); + const path = '/repos/' + config.owner + '/' + config.repo + '/contents/' + repoPath + '?ref=' + encodeURIComponent(branch); + return giteaSyncRequest('GET', path, null); +} + +function upsertGiteaSyncFile(repoPath, content, branch, message) { + const config = getSyncConfig(); + const path = '/repos/' + config.owner + '/' + config.repo + '/contents/' + repoPath; + + const metaRes = getGiteaFileMetadata(repoPath, branch); + let sha = null; + + if (metaRes.ok && metaRes.data && metaRes.data.sha) { + sha = metaRes.data.sha; + } else if (!metaRes.ok && metaRes.code !== 404) { + return metaRes; + } + + const payload = { + branch: branch, + content: encodeBase64Utf8(content), + message: message || 'Update ' + repoPath + }; + + if (sha) { + payload.sha = sha; + } + + return giteaSyncRequest(sha ? 'PUT' : 'POST', path, payload); +} + +function getAppsScriptProjectContent() { + const config = getSyncConfig(); + const currentScriptId = ScriptApp.getScriptId(); + const targetScriptId = config.scriptId || currentScriptId; + + console.log("--- Diagnostic: getAppsScriptProjectContent ---"); + console.log("Configured Script ID : " + (config.scriptId || "(empty)")); + console.log("Current Script ID : " + currentScriptId); + + if (config.scriptId && config.scriptId !== currentScriptId) { + console.warn("MISMATCH: Configured SCRIPT_ID is different from current ScriptApp.getScriptId(). This may cause a 403 error."); + } else { + console.log("MATCH: Script IDs match (or using default fallback)."); + } + + const url = "https://script.googleapis.com/v1/projects/" + targetScriptId + "/content"; + const token = ScriptApp.getOAuthToken(); + const options = { + method: "get", + headers: { + Authorization: "Bearer " + token + }, + muteHttpExceptions: true + }; + + const response = UrlFetchApp.fetch(url, options); + const code = response.getResponseCode(); + const rawContent = response.getContentText(); + + if (code === 200) { + const data = JSON.parse(rawContent); + const files = data.files || []; + console.log("Response Code : " + code); + console.log("Files Retrieved : " + files.length); + console.log("File Names : " + files.map(function(f){ return f.name; }).join(", ")); + console.log("-----------------------------------------------"); + return { ok: true, data: data }; + } + + console.log("Response Code : " + code); + console.log("Raw Response Body : " + rawContent); + console.log("-----------------------------------------------"); + + let errorMsg = "Failed to fetch project content. Code: " + code; + if (code === 403) { + errorMsg = "HTTP 403 Forbidden. Ensure Google Apps Script API is enabled in user settings (https://script.google.com/home/usersettings) and OAuth scopes are correctly configured."; + } + + return { ok: false, message: errorMsg, code: code, rawBody: rawContent }; +} + +function normalizeAppsScriptFiles(files) { + return files.map(function(file) { + return { + name: file.name, + type: file.type, + source: file.source || '' + }; + }); +} + +function exportAppsScriptProject() { + const res = getAppsScriptProjectContent(); + if (!res.ok) return res; + return { ok: true, files: normalizeAppsScriptFiles(res.data.files || []) }; +} + +function buildSyncManifest(featureCode, mappedFiles) { + const manifest = { + featureCode: featureCode, + syncDate: new Date().toISOString(), + filesSynced: mappedFiles.length + }; + return JSON.stringify(manifest, null, 2); +} + +function checkGiteaPullRequestExists(baseBranch, headBranch) { + const config = getSyncConfig(); + const path = '/repos/' + config.owner + '/' + config.repo + '/pulls?state=open'; + const res = giteaSyncRequest('GET', path, null); + if (res.ok && res.data && Array.isArray(res.data)) { + const existing = res.data.find(function(pr) { + return pr.base && pr.head && pr.base.ref === baseBranch && pr.head.ref === headBranch; + }); + if (existing) { + return { ok: true, existed: true, data: existing }; + } + return { ok: true, existed: false }; + } + return { ok: false, existed: false, message: res.message || 'Failed to check existing PRs' }; +} + +function createGiteaPullRequest(title, body, baseBranch, headBranch) { + const config = getSyncConfig(); + + const checkRes = checkGiteaPullRequestExists(baseBranch, headBranch); + if (checkRes.existed) { + return { ok: true, existed: true, data: checkRes.data, message: 'Pull request already exists' }; + } + if (!checkRes.ok) { + return checkRes; + } + + const path = '/repos/' + config.owner + '/' + config.repo + '/pulls'; + const payload = { + title: title, + body: body, + base: baseBranch, + head: headBranch + }; + const res = giteaSyncRequest('POST', path, payload); + if (res.ok) { + res.existed = false; + } + return res; +} + +function ensureGiteaBranch(baseBranch, newBranch) { + const config = getSyncConfig(); + const checkRes = giteaSyncRequest('GET', '/repos/' + config.owner + '/' + config.repo + '/branches/' + newBranch, null); + if (checkRes.ok) { + return { ok: true, existed: true }; + } + + const createRes = createGiteaSyncBranch(baseBranch, newBranch); + + if (!createRes.ok && createRes.message && createRes.message.toLowerCase().indexOf('empty') !== -1) { + console.log('Git Repository is empty. Attempting to bootstrap with initial commit...'); + const bootstrapRes = giteaSyncRequest('POST', '/repos/' + config.owner + '/' + config.repo + '/contents/README.md', { + branch: baseBranch || config.defaultBranch, + content: encodeBase64Utf8('# SchoolHub Sync\nRepository initialized automatically.'), + message: 'Initial commit' + }); + + if (bootstrapRes.ok) { + console.log('Bootstrap successful. Retrying branch creation...'); + return createGiteaSyncBranch(baseBranch, newBranch); + } + return { ok: false, message: 'Git Repository is empty and bootstrap failed: ' + bootstrapRes.message + '. Harap inisialisasi repositori secara manual (misal: centang "Initialize Repository" saat membuat repo).' }; + } + + return createRes; +} + + +function syncAppsScriptToGiteaBranch(featureCode) { + console.log("Starting Gitea sync for feature: " + featureCode); + const projectRes = exportAppsScriptProject(); + if (!projectRes.ok) return projectRes; + + const payload = prepareGitSyncPayload(featureCode, projectRes.files); + if (!payload.files || payload.files.length === 0) { + return { ok: false, message: 'No valid files to sync.' }; + } + + const branchRes = ensureGiteaBranch(payload.baseBranch, payload.syncBranch); + if (!branchRes.ok) return { ok: false, message: 'Branch creation failed: ' + branchRes.message }; + + console.log("Branch ready: " + payload.syncBranch + " (Existed: " + !!branchRes.existed + ")"); + + const results = { created: 0, updated: 0, errors: 0, details: [] }; + + payload.files.forEach(function(file) { + const res = upsertGiteaSyncFile(file.repoPath, file.content, payload.syncBranch, 'Sync ' + file.repoPath); + results.details.push({ file: file.repoPath, ok: res.ok, message: res.message }); + if (res.ok) { + if (res.code === 201) results.created++; + else results.updated++; + } else { + results.errors++; + } + }); + + const manifestContent = buildSyncManifest(featureCode, payload.files); + const manifestRes = upsertGiteaSyncFile('sync-manifest.json', manifestContent, payload.syncBranch, 'Update sync manifest'); + if (manifestRes.ok) { + if (manifestRes.code === 201) results.created++; + else results.updated++; + } else { + results.errors++; + } + + const prTitle = 'Sync Feature: ' + featureCode; + const prBody = 'Automated sync from Google Apps Script for feature: ' + featureCode + '.\n\nFiles synced:\n' + payload.files.map(function(f) { return '- ' + f.repoPath; }).join('\n'); + const prRes = createGiteaPullRequest(prTitle, prBody, payload.baseBranch, payload.syncBranch); + + let prInfo = null; + if (prRes.ok) { + prInfo = { + url: prRes.data.html_url || '', + existed: prRes.existed, + number: prRes.data.number + }; + } + + console.log("Sync Summary: " + results.created + " created, " + results.updated + " updated, " + results.errors + " errors."); + if (prInfo) { + console.log("Pull Request: " + prInfo.url + (prInfo.existed ? " (Existed)" : " (Created)")); + } else { + console.log("Pull Request Failed: " + prRes.message); + } + + return { ok: true, branch: payload.syncBranch, summary: results, pullRequest: prInfo }; +} + +function runManualSyncToGitea() { + const res = syncAppsScriptToGiteaBranch('manual'); + if (res.ok) { + console.log('=== Sync Summary ==='); + console.log('Branch Sync: ' + res.branch); + console.log('Files: ' + res.summary.created + ' created, ' + res.summary.updated + ' updated, ' + res.summary.errors + ' errors.'); + console.log('Catatan: File baru akan menggunakan path app/, docs/, dan workflow/. File lama dengan folder src/ mungkin masih tertinggal di repositori (Gitea) dan harus dihapus secara manual.'); + if (res.pullRequest) { + console.log('Pull Request Status: ' + (res.pullRequest.existed ? 'Existing' : 'Created')); + console.log('Pull Request URL: ' + res.pullRequest.url); + } else { + console.log('Pull Request Status: Not available'); + } + } else { + console.error('Sync failed: ' + res.message); + } + return res; +} + -- 2.52.0 From 9841953e8f5fb5f5606dd51080276a981b117b28 Mon Sep 17 00:00:00 2001 From: someone <2+someone@noreply.localhost> Date: Fri, 24 Apr 2026 23:45:08 +0000 Subject: [PATCH 13/13] Update sync manifest --- sync-manifest.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 sync-manifest.json diff --git a/sync-manifest.json b/sync-manifest.json new file mode 100644 index 0000000..830a1f6 --- /dev/null +++ b/sync-manifest.json @@ -0,0 +1,5 @@ +{ + "featureCode": "manual", + "syncDate": "2026-04-24T23:45:06.978Z", + "filesSynced": 12 +} \ No newline at end of file -- 2.52.0