Memeriksa Koneksi...
Data ini menjadi identitas PDF dan akun pengelola pertama.
Dokumentasi Klinis Apotek & Integrasi WhatsApp
Instalasi baru?
Admin pertama ditentukan melalui Setup Awal. Tidak ada password demo yang ditampilkan pada aplikasi.
Pratinjau & sesuaikan pesan sebelum dikirim
Format otomatis dikonversi ke format internasional (+62).
Build cloud terhubung langsung ke Google Apps Script. Web App harus dijalankan sebagai pemilik dan dapat diakses tanpa login Google.
Backup JSON dapat berisi data pasien dan hash akun. Simpan file backup di lokasi yang aman.
// ApoCare Pro Sell-Ready Backend v1.0.0
// Database Google Sheets + autentikasi + dokumentasi layanan kefarmasian.
// Spreadsheet tujuan untuk mode standalone
var APOCARE_SPREADSHEET_ID = '1fTuqR7T1-HA1Ya8ddJASSwy-os3y3BYSX6E8bHFzGFU';
var SHEET_HEADERS = {
Pengkajian: [
'id','noResep','tanggal','pasien','namaPasien','noHp','umur','bbTb',
'dokter','namaDokter','diagnosa','riwayatAlergi','kehamilan',
'penyakitPenyerta','checklist','kesimpulan','statusPengkajian','catatan'
],
PIO: [
'id','tanggal','pasien','namaPasien','penanya','namaPenanya','noHp',
'jenisPelayanan','kategoriPenanya','materi','catatan'
],
Konseling: [
'id','tanggal','pasien','namaPasien','noHp','diagnosis','obat','teachBack','catatan'
],
CekKesehatan: [
'id','tanggal','pasien','namaPasien','noHp','jenisKelamin','sistol','diastol','gulaDarah',
'jenisGula','kolesterol','asamUrat','statusTekananDarah','statusGulaDarah',
'statusKolesterol','statusAsamUrat','statusKeseluruhan','catatan'
],
MESO: [
'id','tanggal','pasien','namaPasien','noHp','obatDicurigai','indikasi','dosis','waktuMulai',
'efekSamping','waktuKejadian','tingkatKeparahan','tindakan','outcome','kausalitas','catatan'
],
PMR: [
'id','tanggal','pasien','namaPasien','noHp','diagnosis','targetTerapi','obat','dosis',
'kepatuhan','efektivitas','keamanan','masalahTerkaitObat','intervensi','tindakLanjut','catatan'
],
HomeCare: [
'id','tanggal','pasien','namaPasien','noHp','alamat','diagnosis','tujuanKunjungan','obat',
'hasilPemantauan','kepatuhan','keluhan','intervensi','tindakLanjut','jadwalKunjunganBerikut','catatan'
],
ProfilApotek: [
'id','namaApotek','alamatApotek','teleponApotek','emailApotek','namaApoteker','nomorSipa','jabatanApoteker','kotaApotek','updatedAt'
],
Pasien: [
'id','nama','noHp','umur','bbTb','riwayatAlergi'
],
Users: [
'id','username','passwordHash','passwordSalt','nama','role','status','createdAt','updatedAt'
]
};
var SESSION_SECONDS = 21600; // 6 jam
/**
* JALANKAN SEKALI SECARA MANUAL dari editor Apps Script.
* Mode standalone: script langsung membuka Google Sheet berdasarkan ID di atas,
* lalu membuat seluruh sheet + header yang diperlukan ApoCare Pro.
*/
function setupDatabase() {
var ss = SpreadsheetApp.openById(APOCARE_SPREADSHEET_ID);
PropertiesService.getScriptProperties().setProperty('APOCARE_SPREADSHEET_ID', APOCARE_SPREADSHEET_ID);
ensureDatabaseStructure(ss);
SpreadsheetApp.flush();
return 'Database ApoCare Pro siap pada Spreadsheet ID: ' + APOCARE_SPREADSHEET_ID;
}
function getDatabase() {
var spreadsheetId = PropertiesService.getScriptProperties().getProperty('APOCARE_SPREADSHEET_ID') || APOCARE_SPREADSHEET_ID;
if (!spreadsheetId) {
throw new Error('APOCARE_SPREADSHEET_ID belum diisi.');
}
return SpreadsheetApp.openById(spreadsheetId);
}
function ensureDatabaseStructure(ss) {
for (var sheetName in SHEET_HEADERS) {
if (SHEET_HEADERS.hasOwnProperty(sheetName)) {
ensureSheetHeaders(ss, sheetName, SHEET_HEADERS[sheetName]);
}
}
}
function ensureSheetHeaders(ss, sheetName, requiredHeaders) {
var sheet = ss.getSheetByName(sheetName);
if (!sheet) sheet = ss.insertSheet(sheetName);
var existingHeaders = [];
var lastColumn = sheet.getLastColumn();
var lastRow = sheet.getLastRow();
if (lastRow >= 1 && lastColumn >= 1) {
var rawHeaders = sheet.getRange(1, 1, 1, lastColumn).getValues()[0];
for (var i = 0; i < rawHeaders.length; i++) {
var value = String(rawHeaders[i] || '').trim();
if (value) existingHeaders.push(value);
}
}
if (existingHeaders.length === 0) {
sheet.clearContents();
sheet.getRange(1, 1, 1, requiredHeaders.length).setValues([requiredHeaders]);
} else {
for (var h = 0; h < requiredHeaders.length; h++) {
var header = requiredHeaders[h];
if (existingHeaders.indexOf(header) === -1) {
existingHeaders.push(header);
sheet.getRange(1, existingHeaders.length).setValue(header);
}
}
}
var finalLastColumn = sheet.getLastColumn();
if (finalLastColumn > 0) {
sheet.getRange(1, 1, 1, finalLastColumn).setFontWeight('bold');
sheet.setFrozenRows(1);
}
return sheet;
}
function doGet() {
try {
var ss = getDatabase();
ensureDatabaseStructure(ss);
return ContentService.createTextOutput('ApoCare Pro Backend Active - Database Ready').setMimeType(ContentService.MimeType.TEXT);
} catch (err) {
return ContentService.createTextOutput('ApoCare Pro Backend Error: ' + String(err)).setMimeType(ContentService.MimeType.TEXT);
}
}
function doPost(e) {
try {
if (!e || !e.postData || !e.postData.contents) {
return responseJSON({ status: 'error', message: 'Request body kosong' });
}
var contents = JSON.parse(e.postData.contents);
var action = contents.action || '';
var data = contents.data || {};
var token = contents.token || '';
var ss = getDatabase();
ensureDatabaseStructure(ss);
// Public health check: verifies POST Web App + Google Sheet access without requiring a login session.
if (action === 'ping') {
return responseJSON({
status: 'success',
backend: 'ApoCare Pro',
databaseReady: true,
version: '1.0.1-standalone-v6',
serverTime: new Date().toISOString()
});
}
if (action === 'bootstrapAdmin') return handleBootstrapAdmin(ss, data);
if (action === 'login') return handleUserLogin(ss, data);
if (action === 'validateSession') return handleValidateSession(token);
if (action === 'logout') return handleLogoutSession(token);
var session = requireSession(token);
if (action === 'getAllData') {
return responseJSON({
status: 'success',
data: {
pengkajian: getSheetData(ss, 'Pengkajian'),
pio: getSheetData(ss, 'PIO'),
konseling: getSheetData(ss, 'Konseling'),
cekKesehatan: getSheetData(ss, 'CekKesehatan'),
meso: getSheetData(ss, 'MESO'),
pmr: getSheetData(ss, 'PMR'),
homeCare: getSheetData(ss, 'HomeCare'),
profilApotek: getSheetData(ss, 'ProfilApotek'),
pasien: getSheetData(ss, 'Pasien')
}
});
}
if (action === 'listUsers') {
requireAdmin(session);
return responseJSON({ status: 'success', data: listUsersSafe(ss) });
}
if (action === 'saveUser') {
requireAdmin(session);
return responseJSON({ status: 'success', data: saveManagedUser(ss, data) });
}
if (action === 'deleteUser') {
requireAdmin(session);
deleteManagedUser(ss, data.id, session);
return responseJSON({ status: 'success' });
}
if (action === 'savePengkajian') { saveToSheet(ss, 'Pengkajian', data); return responseJSON({ status: 'success' }); }
if (action === 'deletePengkajian') { deleteFromSheet(ss, 'Pengkajian', data.id); return responseJSON({ status: 'success' }); }
if (action === 'savePIO') { saveToSheet(ss, 'PIO', data); return responseJSON({ status: 'success' }); }
if (action === 'deletePIO') { deleteFromSheet(ss, 'PIO', data.id); return responseJSON({ status: 'success' }); }
if (action === 'saveKonseling') { saveToSheet(ss, 'Konseling', data); return responseJSON({ status: 'success' }); }
if (action === 'deleteKonseling') { deleteFromSheet(ss, 'Konseling', data.id); return responseJSON({ status: 'success' }); }
if (action === 'saveCekKesehatan') { saveToSheet(ss, 'CekKesehatan', data); return responseJSON({ status: 'success' }); }
if (action === 'deleteCekKesehatan') { deleteFromSheet(ss, 'CekKesehatan', data.id); return responseJSON({ status: 'success' }); }
if (action === 'saveMESO') { saveToSheet(ss, 'MESO', data); return responseJSON({ status: 'success' }); }
if (action === 'deleteMESO') { deleteFromSheet(ss, 'MESO', data.id); return responseJSON({ status: 'success' }); }
if (action === 'savePMR') { saveToSheet(ss, 'PMR', data); return responseJSON({ status: 'success' }); }
if (action === 'deletePMR') { deleteFromSheet(ss, 'PMR', data.id); return responseJSON({ status: 'success' }); }
if (action === 'saveHomeCare') { saveToSheet(ss, 'HomeCare', data); return responseJSON({ status: 'success' }); }
if (action === 'deleteHomeCare') { deleteFromSheet(ss, 'HomeCare', data.id); return responseJSON({ status: 'success' }); }
if (action === 'saveProfilApotek') { requireAdmin(session); data.id = 'PROFILE-1'; saveToSheet(ss, 'ProfilApotek', data); return responseJSON({ status: 'success' }); }
if (action === 'savePasien') { saveToSheet(ss, 'Pasien', data); return responseJSON({ status: 'success' }); }
return responseJSON({ status: 'error', message: 'Action tidak dikenal: ' + action });
} catch (err) {
return responseJSON({ status: 'error', message: String(err && err.message ? err.message : err) });
}
}
function responseJSON(obj) {
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(ContentService.MimeType.JSON);
}
// ==================== AUTH / USER MANAGEMENT ====================
function handleBootstrapAdmin(ss, data) {
var users = getSheetData(ss, 'Users');
if (users.length > 0) {
return responseJSON({ status: 'error', message: 'Setup awal backend sudah pernah dilakukan. Login sebagai Admin untuk mengelola user.' });
}
var username = String(data.username || '').trim();
var nama = String(data.nama || '').trim();
var password = String(data.password || '');
if (!username || !nama || !password) return responseJSON({ status: 'error', message: 'Username, nama Admin, dan password wajib diisi.' });
if (!/^[A-Za-z0-9._-]{3,30}$/.test(username)) return responseJSON({ status: 'error', message: 'Username 3-30 karakter dan hanya boleh huruf, angka, titik, garis bawah, atau tanda minus.' });
if (password.length < 8) return responseJSON({ status: 'error', message: 'Password Admin minimal 8 karakter.' });
var salt = Utilities.getUuid();
var now = new Date().toISOString();
var item = {
id: 'U-' + Utilities.getUuid().slice(0, 8).toUpperCase(),
username: username,
passwordHash: hashPassword(password, salt),
passwordSalt: salt,
nama: nama,
role: 'Admin',
status: 'Aktif',
createdAt: now,
updatedAt: now
};
saveToSheet(ss, 'Users', item);
if (data.profil && typeof data.profil === 'object') {
var profil = data.profil;
profil.id = 'PROFILE-1';
profil.updatedAt = now;
saveToSheet(ss, 'ProfilApotek', profil);
}
return responseJSON({ status: 'success', data: sanitizeUser(item), message: 'Admin pertama berhasil dibuat.' });
}
function countActiveAdmins(users, excludeId) {
var count = 0;
for (var i = 0; i < users.length; i++) {
if (excludeId && String(users[i].id || '') === String(excludeId)) continue;
if (String(users[i].role || '').toLowerCase() === 'admin' && String(users[i].status || '').toLowerCase() === 'aktif') count++;
}
return count;
}
function hashPassword(password, salt) {
var input = String(salt || '') + '|' + String(password || '');
var bytes = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, input, Utilities.Charset.UTF_8);
var hex = '';
for (var i = 0; i < bytes.length; i++) {
var v = bytes[i];
if (v < 0) v += 256;
var h = v.toString(16);
if (h.length === 1) h = '0' + h;
hex += h;
}
return hex;
}
function handleUserLogin(ss, data) {
var username = String(data.username || '').trim().toLowerCase();
var password = String(data.password || '');
if (!username || !password) return responseJSON({ status: 'error', message: 'Username dan password wajib diisi.' });
var users = getSheetData(ss, 'Users');
var found = null;
for (var i = 0; i < users.length; i++) {
if (String(users[i].username || '').trim().toLowerCase() === username) {
found = users[i];
break;
}
}
if (!found || String(found.status || '').toLowerCase() !== 'aktif') {
return responseJSON({ status: 'error', message: 'Username/password salah atau akun tidak aktif.' });
}
var expected = String(found.passwordHash || '');
var actual = hashPassword(password, String(found.passwordSalt || ''));
if (!expected || expected !== actual) {
return responseJSON({ status: 'error', message: 'Username/password salah atau akun tidak aktif.' });
}
var user = sanitizeUser(found);
var token = Utilities.getUuid() + Utilities.getUuid();
saveSession(token, user);
pruneExpiredSessions();
return responseJSON({ status: 'success', token: token, user: user });
}
function sessionKey(token) {
return 'session_' + String(token || '');
}
function saveSession(token, user) {
var key = sessionKey(token);
var envelope = {
user: user,
expiresAt: Date.now() + (SESSION_SECONDS * 1000)
};
var raw = JSON.stringify(envelope);
PropertiesService.getScriptProperties().setProperty(key, raw);
try { CacheService.getScriptCache().put(key, raw, SESSION_SECONDS); } catch (cacheErr) {}
}
function removeSession(token) {
if (!token) return;
var key = sessionKey(token);
PropertiesService.getScriptProperties().deleteProperty(key);
try { CacheService.getScriptCache().remove(key); } catch (cacheErr) {}
}
function pruneExpiredSessions() {
var props = PropertiesService.getScriptProperties();
var all = props.getProperties();
var now = Date.now();
for (var key in all) {
if (!all.hasOwnProperty(key) || key.indexOf('session_') !== 0) continue;
try {
var parsed = JSON.parse(all[key]);
if (!parsed || !parsed.expiresAt || now > Number(parsed.expiresAt)) props.deleteProperty(key);
} catch (e) {
props.deleteProperty(key);
}
}
}
function handleValidateSession(token) {
try {
var session = requireSession(token);
return responseJSON({ status: 'success', user: session });
} catch (err) {
return responseJSON({ status: 'error', message: 'Sesi login berakhir. Silakan login kembali.' });
}
}
function handleLogoutSession(token) {
removeSession(token);
return responseJSON({ status: 'success' });
}
function requireSession(token) {
if (!token) throw new Error('Sesi login tidak tersedia. Silakan login kembali.');
var key = sessionKey(token);
var raw = null;
try { raw = CacheService.getScriptCache().get(key); } catch (cacheErr) {}
if (!raw) raw = PropertiesService.getScriptProperties().getProperty(key);
if (!raw) throw new Error('Sesi login berakhir. Silakan login kembali.');
var parsed = JSON.parse(raw);
var user = parsed && parsed.user ? parsed.user : parsed;
var expiresAt = parsed && parsed.expiresAt ? Number(parsed.expiresAt) : 0;
if (expiresAt && Date.now() > expiresAt) {
removeSession(token);
throw new Error('Sesi login berakhir. Silakan login kembali.');
}
// Sliding session: setiap request valid memperpanjang masa sesi.
saveSession(token, user);
return user;
}
function requireAdmin(session) {
if (!session || String(session.role || '').toLowerCase() !== 'admin') {
throw new Error('Akses ditolak. Menu ini hanya untuk Admin.');
}
}
function sanitizeUser(user) {
return {
id: String(user.id || ''),
username: String(user.username || ''),
nama: String(user.nama || user.username || ''),
role: String(user.role || 'Petugas'),
status: String(user.status || 'Aktif'),
createdAt: String(user.createdAt || ''),
updatedAt: String(user.updatedAt || '')
};
}
function listUsersSafe(ss) {
var users = getSheetData(ss, 'Users');
var result = [];
for (var i = 0; i < users.length; i++) result.push(sanitizeUser(users[i]));
return result;
}
function saveManagedUser(ss, data) {
var id = String(data.id || '').trim();
var username = String(data.username || '').trim();
var usernameLower = username.toLowerCase();
var nama = String(data.nama || '').trim();
var role = String(data.role || 'Petugas') === 'Admin' ? 'Admin' : 'Petugas';
var status = String(data.status || 'Aktif') === 'Nonaktif' ? 'Nonaktif' : 'Aktif';
var password = String(data.password || '');
if (!username || !nama) throw new Error('Username dan nama wajib diisi.');
if (!/^[A-Za-z0-9._-]{3,30}$/.test(username)) throw new Error('Username 3-30 karakter dan hanya boleh huruf, angka, titik, garis bawah, atau tanda minus.');
var users = getSheetData(ss, 'Users');
var existing = null;
for (var i = 0; i < users.length; i++) {
var candidate = users[i];
if (String(candidate.username || '').trim().toLowerCase() === usernameLower && String(candidate.id || '') !== id) {
throw new Error('Username sudah digunakan.');
}
if (id && String(candidate.id || '') === id) existing = candidate;
}
var now = new Date().toISOString();
var item;
if (existing) {
if (String(existing.role || '').toLowerCase() === 'admin' && String(existing.status || '').toLowerCase() === 'aktif' && (role !== 'Admin' || status !== 'Aktif') && countActiveAdmins(users, existing.id) === 0) {
throw new Error('Minimal satu Admin aktif harus tetap tersedia.');
}
item = {
id: String(existing.id),
username: username,
passwordHash: String(existing.passwordHash || ''),
passwordSalt: String(existing.passwordSalt || ''),
nama: nama,
role: role,
status: status,
createdAt: String(existing.createdAt || now),
updatedAt: now
};
if (password) {
if (password.length < 8) throw new Error('Password baru minimal 8 karakter.');
item.passwordSalt = Utilities.getUuid();
item.passwordHash = hashPassword(password, item.passwordSalt);
}
} else {
if (password.length < 8) throw new Error('Password minimal 8 karakter untuk user baru.');
var salt = Utilities.getUuid();
item = {
id: 'U-' + Utilities.getUuid().slice(0, 8).toUpperCase(),
username: username,
passwordHash: hashPassword(password, salt),
passwordSalt: salt,
nama: nama,
role: role,
status: status,
createdAt: now,
updatedAt: now
};
}
saveToSheet(ss, 'Users', item);
return sanitizeUser(item);
}
function deleteManagedUser(ss, userId, session) {
userId = String(userId || '');
if (!userId) throw new Error('ID user tidak tersedia.');
if (String(session.id || '') === userId) throw new Error('Akun yang sedang digunakan tidak dapat dihapus.');
var users = getSheetData(ss, 'Users');
var target = null;
for (var i = 0; i < users.length; i++) {
if (String(users[i].id || '') === userId) { target = users[i]; break; }
}
if (target && String(target.role || '').toLowerCase() === 'admin' && String(target.status || '').toLowerCase() === 'aktif' && countActiveAdmins(users, userId) === 0) {
throw new Error('Admin aktif terakhir tidak dapat dihapus.');
}
deleteFromSheet(ss, 'Users', userId);
}
// ==================== GENERIC SHEET HELPERS ====================
function getSheetData(ss, sheetName) {
var requiredHeaders = SHEET_HEADERS[sheetName] || [];
var sheet = ensureSheetHeaders(ss, sheetName, requiredHeaders);
if (sheet.getLastRow() < 2) return [];
var lastColumn = sheet.getLastColumn();
var data = sheet.getRange(1, 1, sheet.getLastRow(), lastColumn).getValues();
var headers = data[0];
var result = [];
for (var i = 1; i < data.length; i++) {
var row = data[i];
var obj = {};
var hasValue = false;
for (var j = 0; j < headers.length; j++) {
var header = String(headers[j] || '').trim();
if (!header) continue;
var val = row[j];
if (val !== '' && val !== null) hasValue = true;
if (header === 'checklist' || header === 'materi') {
if (typeof val === 'string' && val !== '') {
try { val = JSON.parse(val); } catch (parseErr) {}
}
}
obj[header] = val;
}
if (hasValue) result.push(obj);
}
return result;
}
function saveToSheet(ss, sheetName, item) {
if (!item || typeof item !== 'object') throw new Error('Data yang akan disimpan tidak valid');
var itemKeys = Object.keys(item);
if (itemKeys.length === 0) throw new Error('Data kosong');
var baseHeaders = SHEET_HEADERS[sheetName] || [];
var requiredHeaders = baseHeaders.slice();
for (var k = 0; k < itemKeys.length; k++) {
if (requiredHeaders.indexOf(itemKeys[k]) === -1) requiredHeaders.push(itemKeys[k]);
}
var sheet = ensureSheetHeaders(ss, sheetName, requiredHeaders);
var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
for (var h = 0; h < headers.length; h++) headers[h] = String(headers[h] || '').trim();
var existingRowIndex = -1;
var idColIndex = headers.indexOf('id');
if (idColIndex !== -1 && sheet.getLastRow() > 1 && item.id !== undefined) {
var idValues = sheet.getRange(2, idColIndex + 1, sheet.getLastRow() - 1, 1).getValues();
for (var r = 0; r < idValues.length; r++) {
if (String(idValues[r][0]) === String(item.id)) { existingRowIndex = r + 2; break; }
}
}
var rowData = [];
for (var c = 0; c < headers.length; c++) {
var headerName = headers[c];
var value = item[headerName] !== undefined ? item[headerName] : '';
if (value !== null && typeof value === 'object') value = JSON.stringify(value);
rowData.push(value);
}
if (existingRowIndex !== -1) sheet.getRange(existingRowIndex, 1, 1, rowData.length).setValues([rowData]);
else sheet.appendRow(rowData);
}
function deleteFromSheet(ss, sheetName, itemId) {
var requiredHeaders = SHEET_HEADERS[sheetName] || [];
var sheet = ensureSheetHeaders(ss, sheetName, requiredHeaders);
if (sheet.getLastRow() < 2) return;
var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
var idColIndex = -1;
for (var i = 0; i < headers.length; i++) {
if (String(headers[i]).trim() === 'id') { idColIndex = i; break; }
}
if (idColIndex === -1) return;
var idValues = sheet.getRange(2, idColIndex + 1, sheet.getLastRow() - 1, 1).getValues();
for (var r = 0; r < idValues.length; r++) {
if (String(idValues[r][0]) === String(itemId)) {
sheet.deleteRow(r + 2);
return;
}
}
}
Apakah Anda yakin ingin menghapus data ini dari database?