/************************************************************

* PADELOS ENTERPRISE IDENTITY & MANAGEMENT CORE v4.2

* Gekoppeld aan Spreadsheet: 1Rap-6ENmSOwF-lkSZuwxxkJfVWVyfNqRsSD0h9L4uF8

************************************************************/


const P12345 = Object.freeze({

ย VERSION: '4.2.0',

ย SPREADSHEET_ID: '1Rap-6ENmSOwF-lkSZuwxxkJfVWVyfNqRsSD0h9L4uF8',

ย ADMIN_EMAIL: 'padelstijn@gmail.com',

ย CODE_MINUTES: 10,

ย SESSION_HOURS: 24,

ย TABS: {

ย ย ย BERICHTEN_SPELERS: ['ID','GESPREK_ID','AFZENDER_ID','ONTVANGER_ID','GROEP_ID','LES_ID','ONDERWERP','BERICHT','KANAAL','GELEZEN_AT','STATUS','CREATED_AT','UPDATED_AT','TEAM_ID','BERICHT_TYPE','MEDIA_URL','LINKS'],

ย ย ย BERICHTEN_TEAM: ['ID','GESPREK_ID','AFZENDER_ID','ONTVANGER_ID','GROEP_ID','LES_ID','ONDERWERP','BERICHT','KANAAL','GELEZEN_AT','STATUS','CREATED_AT','UPDATED_AT','TEAM_ID','BERICHT_TYPE','MEDIA_URL','LINKS'],

ย ย ย BERICHTEN_ADMIN: ['ID','GESPREK_ID','AFZENDER_ID','ONTVANGER_ID','GROEP_ID','LES_ID','ONDERWERP','BERICHT','KANAAL','GELEZEN_AT','STATUS','CREATED_AT','UPDATED_AT','TEAM_ID','BERICHT_TYPE','MEDIA_URL','LINKS'],

ย ย ย BERICHTEN_FINANCIEEL: ['ID','GESPREK_ID','AFZENDER_ID','ONTVANGER_ID','LES_ID','ONDERWERP','BERICHT','DATUM','STARTTIJD','EINDTIJD','LESDUUR','PRIJS_EXCL_BTW','BTW_TARIEF','PRIJS_INCL_BTW','TRAINER_VERGOEDING','BAAN_HUURKOSTEN','MATERIAAL_KOSTEN','GEWENSTE_MATERIALEN','AANWEZIGE_MATERIALEN','SLEUTEL_INFO','INGANG_CODES','BETAALSTATUS','BEVESTIGING_STATUS','CREATED_AT','UPDATED_AT'],

ย ย ย GEBRUIKERS: ['ID','EMAIL','VOORNAAM','ACHTERNAAM','TELEFOON','PASSWORD_HASH','PASSWORD_SALT','EMAIL_VERIFIED','LAST_LOGIN','STATUS','CREATED_AT','UPDATED_AT'],

ย ย ย GEBRUIKER_ROLLEN: ['ID','USER_ID','ROLE_CODE','STATUS','CREATED_AT','UPDATED_AT'],

ย ย ย LOCATIES: ['ID','NAAM','ADRES','POSTCODE','PLAATS','CONTACTPERSOON','TELEFOON','EMAIL','STATUS','CREATED_AT','UPDATED_AT'],

ย ย ย BANEN: ['ID','LOCATIE_ID','NAAM','NUMMER','TYPE','INDOOR','OVERDEKT','STATUS','CREATED_AT','UPDATED_AT'],

ย ย ย BOEKINGEN: ['ID','USER_ID','DATUM','TIJD','LOCATIE','TYPE','STATUS','TRAINER_ID','OPMERKING','CREATED_AT','UPDATED_AT'],

ย ย ย PROFIELEN: ['ID','USER_ID','WEERGAVENAAM','FOTO_URL','BIO','TAAL','LETTERGROOTTE','THEMA','BG_KLEUR','KAART_KLEUR','TEKST_KLEUR','ACCENT_KLEUR','QUOTE','SPEELSTERKTE','BONDSNUMMER','STATUS','CREATED_AT','UPDATED_AT'],

ย ย ย ROLLEN: ['ID','CODE','NAAM','OMSCHRIJVING','STATUS','CREATED_AT','UPDATED_AT'],

ย ย ย SESSIES: ['ID','USER_ID','TOKEN_HASH','EXPIRES_AT','LAST_USED_AT','STATUS','CREATED_AT','UPDATED_AT'],

ย ย ย LOGIN_CODES: ['ID','EMAIL','CODE_HASH','PURPOSE','EXPIRES_AT','ATTEMPTS','USED_AT','STATUS','CREATED_AT','UPDATED_AT'],

ย ย ย TRAINER_BESCHIKBAARHEID: ['ID','USER_ID','TYPE','START_DATUM','EIND_DATUM','TIJD','STATUS','TOELICHTING','CREATED_AT','UPDATED_AT']

ย }

});


function doGet(e) {

ย return HtmlService.createTemplateFromFile('Index').evaluate()

ย ย ย .setTitle('PadelOS โ€” Multi-Role Management Portal')

ย ย ย .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)

ย ย ย .addMetaTag('viewport', 'width=device-width, initial-scale=1');

}


function setupPadelOS12345() {

ย const lock = LockService.getScriptLock();

ย lock.waitLock(30000);

ย try {

ย ย ย Object.entries(P12345.TABS).forEach(([n, h]) => ensureTab_(n, h));

ย ย 

ย ย ย if (!PropertiesService.getScriptProperties().getProperty('PADELOS_SECRET')) {

ย ย ย ย ย PropertiesService.getScriptProperties().setProperty('PADELOS_SECRET', token_() + token_());

ย ย ย }


ย ย ย const rollenLijst = [

ย ย ย ย ย ['SPELER', 'Speler', 'Speler Portaal: lessen boeken, leerdoelen & chat'],

ย ย ย ย ย ['TRAINER', 'Trainer', 'Trainers Portaal: Spelers, Teams, Locaties, Uren, Facturatie & Chats'],

ย ย ย ย ย ['ADMIN', 'Administrator', 'Administrator Portaal: Alle tabs, administratie, banen boeken & betalen']

ย ย ย ];


ย ย ย rollenLijst.forEach(([code, naam, desc]) => seedRole_(code, naam, desc));


ย ย ย let admin = find_('GEBRUIKERS', 'EMAIL', P12345.ADMIN_EMAIL);

ย ย ย if (!admin) {

ย ย ย ย ย admin = createUser_(P12345.ADMIN_EMAIL, 'Stijn', 'Gabeler');

ย ย ย }

ย ย ย rollenLijst.forEach(([code]) => ensureUserRole_(admin.ID, code));


ย ย ย return { ok: true, version: P12345.VERSION, message: 'PadelOS succesvol geรฏnitialiseerd!' };

ย } finally {

ย ย ย lock.releaseLock();

ย }

}


function bootstrap(sessionToken) {

ย const auth = auth_(sessionToken);

ย if (!auth) return { ok: true, version: P12345.VERSION, authenticated: false };

ย return userState_(auth.userId, sessionToken);

}


function requestLoginCode(email) {

ย email = normalizeEmail_(email);

ย if (!validEmail_(email)) throw publicError_('Vul een geldig e-mailadres in.');

ย ย const lock = LockService.getScriptLock();

ย lock.waitLock(15000);

ย try {

ย ย ย const code = String(Math.floor(100000 + Math.random() * 900000));

ย ย ย const now = new Date();

ย ย 

ย ย ย append_('LOGIN_CODES', {

ย ย ย ย ย ID: id_('LOGI'), EMAIL: email, CODE_HASH: hash_('code|' + email + '|' + code),

ย ย ย ย ย PURPOSE: 'LOGIN_REGISTER', EXPIRES_AT: new Date(now.getTime() + P12345.CODE_MINUTES * 60000),

ย ย ย ย ย ATTEMPTS: 0, USED_AT: '', STATUS: 'ACTIVE', CREATED_AT: now, UPDATED_AT: now

ย ย ย });


ย ย ย MailApp.sendEmail({

ย ย ย ย ย to: email,

ย ย ย ย ย subject: 'Jouw PadelOS Inlogcode',

ย ย ย ย ย name: 'PadelOS Management',

ย ย ย ย ย htmlBody: '<div style="font-family:Arial,sans-serif"><h2>๐ŸŽพ PadelOS Inlogportaal</h2><p>Jouw eenmalige inlogcode:</p><p style="font-size:32px;font-weight:800;letter-spacing:6px;color:#0e6b56">' + code + '</p></div>'

ย ย ย });


ย ย ย return { ok: true, email, message: 'Verificatiecode verzonden per e-mail.' };

ย } finally {

ย ย ย lock.releaseLock();

ย }

}


function verifyLoginCode(email, code) {

ย email = normalizeEmail_(email); code = String(code || '').trim();

ย const item = rows_('LOGIN_CODES').filter(x => normalizeEmail_(x.data.EMAIL) === email && x.data.STATUS === 'ACTIVE')

ย ย ย .sort((a, b) => dateMs_(b.data.CREATED_AT) - dateMs_(a.data.CREATED_AT))[0];

ย ย 

ย if (!item || dateMs_(item.data.EXPIRES_AT) <= Date.now()) throw publicError_('Deze code is verlopen of niet gevonden.');

ย if (!safeEqual_(item.data.CODE_HASH, hash_('code|' + email + '|' + code))) throw publicError_('De code klopt niet.');

ย ย updateRow_('LOGIN_CODES', item.row, { USED_AT: new Date(), STATUS: 'USED', UPDATED_AT: new Date() });

ย ย let user = find_('GEBRUIKERS', 'EMAIL', email);

ย if (!user) user = createUser_(email, '', '');

ย ย const token = token_();

ย const now = new Date();

ย append_('SESSIES', {

ย ย ย ID: id_('SESS'), USER_ID: user.ID, TOKEN_HASH: hash_('session|' + token),

ย ย ย EXPIRES_AT: new Date(now.getTime() + P12345.SESSION_HOURS * 3600000),

ย ย ย LAST_USED_AT: now, STATUS: 'ACTIVE', CREATED_AT: now, UPDATED_AT: now

ย });

ย ย updateBy_('GEBRUIKERS', 'ID', user.ID, { EMAIL_VERIFIED: 'JA', LAST_LOGIN: now, UPDATED_AT: now });

ย return userState_(user.ID, token);

}


function passwordLogin(email, password) {

ย email = normalizeEmail_(email);

ย const u = find_('GEBRUIKERS', 'EMAIL', email);

ย if (!u) throw publicError_('Gebruiker niet gevonden. Vraag een inlogcode aan.');

ย ย const storedHash = u.PASSWORD_HASH || '';

ย const inputHash = hash_('pwd|' + password + '|' + (u.PASSWORD_SALT || ''));

ย ย if (storedHash && storedHash !== inputHash) throw publicError_('Onjuist wachtwoord.');

ย ย const token = token_();

ย const now = new Date();

ย append_('SESSIES', {

ย ย ย ID: id_('SESS'), USER_ID: u.ID, TOKEN_HASH: hash_('session|' + token),

ย ย ย EXPIRES_AT: new Date(now.getTime() + P12345.SESSION_HOURS * 3600000),

ย ย ย LAST_USED_AT: now, STATUS: 'ACTIVE', CREATED_AT: now, UPDATED_AT: now

ย });

ย ย updateBy_('GEBRUIKERS', 'ID', u.ID, { LAST_LOGIN: now, UPDATED_AT: now });

ย return userState_(u.ID, token);

}


function setPassword(sessionToken, newPassword) {

ย const auth = requireAuth_(sessionToken);

ย const salt = token_().slice(0, 8);

ย const pwdHash = hash_('pwd|' + newPassword + '|' + salt);

ย updateBy_('GEBRUIKERS', 'ID', auth.userId, { PASSWORD_HASH: pwdHash, PASSWORD_SALT: salt, UPDATED_AT: new Date() });

ย return { ok: true, message: 'Wachtwoord succesvol ingesteld!' };

}


function logout(sessionToken) {

ย const a = auth_(sessionToken, true);

ย if (a) updateRow_('SESSIES', a.row, { STATUS: 'REVOKED', UPDATED_AT: new Date() });

ย return { ok: true };

}


function getAllSheetNames(sessionToken) {

ย requireAuth_(sessionToken);

ย return db_().getSheets().map(s => s.getName()).sort();

}


function getTableData(sessionToken, tableName) {

ย requireAuth_(sessionToken);

ย const sh = db_().getSheetByName(tableName);

ย if (!sh) throw publicError_('Tabblad niet gevonden.');

ย const v = sh.getDataRange().getValues();

ย if (v.length === 0) return { ok: true, headers: [], rows: [] };

ย const headers = v[0].map(String);

ย const rows = v.slice(1).map((r, i) => ({

ย ย ย row: i + 2,

ย ย ย data: Object.fromEntries(headers.map((k, j) => [k, r[j]]))

ย }));

ย return { ok: true, headers, rows };

}


function saveTableRow(sessionToken, tableName, rowId, rowData) {

ย requireAuth_(sessionToken);

ย const sh = sheet_(tableName);

ย const headers = headers_(tableName);

ย const now = new Date();

ย ย if (!rowId) {

ย ย ย rowId = id_(tableName.slice(0, 4).toUpperCase());

ย ย ย rowData.ID = rowId;

ย ย ย if (headers.includes('CREATED_AT')) rowData.CREATED_AT = now;

ย ย ย if (headers.includes('UPDATED_AT')) rowData.UPDATED_AT = now;

ย ย ย if (headers.includes('STATUS')) rowData.STATUS = rowData.STATUS || 'ACTIVE';

ย ย ย append_(tableName, rowData);

ย } else {

ย ย ย if (headers.includes('UPDATED_AT')) rowData.UPDATED_AT = now;

ย ย ย updateBy_(tableName, 'ID', rowId, rowData);

ย }

ย return { ok: true, message: 'Succesvol opgeslagen!' };

}


function sendSegmentedChatMessage(sessionToken, data) {

ย const auth = requireAuth_(sessionToken);

ย const now = new Date();

ย let targetTab = 'BERICHTEN_TEAM';

ย if (data.chatType === 'SPELERS') targetTab = 'BERICHTEN_SPELERS';

ย if (data.chatType === 'ADMIN') targetTab = 'BERICHTEN_ADMIN';

ย if (data.chatType === 'FINANCIEEL') targetTab = 'BERICHTEN_FINANCIEEL';


ย const berichtId = id_('BERI');

ย const urlRegex = /(https?:\/\/[^\s]+)/g;

ย const gevondenLinks = data.bericht && data.bericht.match(urlRegex) ? data.bericht.match(urlRegex).join(', ') : '';


ย if (targetTab === 'BERICHTEN_FINANCIEEL') {

ย ย ย append_(targetTab, {

ย ย ย ย ย ID: berichtId, GESPREK_ID: data.gesprekId || 'FIN-BOEKING', AFZENDER_ID: auth.userId,

ย ย ย ย ย ONTVANGER_ID: data.ontvangerId || '', LES_ID: data.lesId || '', ONDERWERP: data.onderwerp || 'Baanreservering & Facturatie',

ย ย ย ย ย BERICHT: data.bericht || '', DATUM: data.datum || '', STARTTIJD: data.starttijd || '', EINDTIJD: data.eindtijd || '',

ย ย ย ย ย LESDUUR: data.lesduur || '60 min', PRIJS_EXCL_BTW: data.prijsExcl || 0, BTW_TARIEF: data.btwTarief || '21%',

ย ย ย ย ย PRIJS_INCL_BTW: data.prijsIncl || 0, TRAINER_VERGOEDING: data.trainerVergoeding || 0, BAAN_HUURKOSTEN: data.baanHuur || 0,

ย ย ย ย ย MATERIAAL_KOSTEN: data.materiaalKosten || 0, GEWENSTE_MATERIALEN: data.gewensteMaterialen || '', AANWEZIGE_MATERIALEN: data.aanwezigeMaterialen || '',

ย ย ย ย ย SLEUTEL_INFO: data.sleutelInfo || '', INGANG_CODES: data.ingangCodes || '', BETAALSTATUS: data.betaalstatus || 'OPEN',

ย ย ย ย ย BEVESTIGING_STATUS: data.bevestigingStatus || 'BEVESTIGD', CREATED_AT: now, UPDATED_AT: now

ย ย ย });

ย } else {

ย ย ย append_(targetTab, {

ย ย ย ย ย ID: berichtId, GESPREK_ID: data.gesprekId || 'GENERAL', AFZENDER_ID: auth.userId, ONTVANGER_ID: data.ontvangerId || '',

ย ย ย ย ย GROEP_ID: data.groepId || 'CLUB-TEAM', LES_ID: data.lesId || '', ONDERWERP: data.onderwerp || 'Chat',

ย ย ย ย ย BERICHT: data.bericht || '', KANAAL: 'PORTAL', GELEZEN_AT: '', STATUS: 'ACTIVE', CREATED_AT: now, UPDATED_AT: now,

ย ย ย ย ย TEAM_ID: data.teamId || '', BERICHT_TYPE: data.mediaUrl ? 'MEDIA' : 'TEKST', MEDIA_URL: data.mediaUrl || '', LINKS: gevondenLinks

ย ย ย });

ย }

ย return { ok: true, message: 'Opgeslagen in ' + targetTab };

}


function getSegmentedChatMessages(sessionToken, chatType, gesprekId) {

ย requireAuth_(sessionToken);

ย let targetTab = 'BERICHTEN_TEAM';

ย if (chatType === 'SPELERS') targetTab = 'BERICHTEN_SPELERS';

ย if (chatType === 'ADMIN') targetTab = 'BERICHTEN_ADMIN';

ย if (chatType === 'FINANCIEEL') targetTab = 'BERICHTEN_FINANCIEEL';


ย const messages = rows_(targetTab).filter(r => !gesprekId || r.data.GESPREK_ID === gesprekId);

ย return { ok: true, messages: messages.map(m => m.data) };

}


function userState_(userId, rawToken) {

ย const u = find_('GEBRUIKERS', 'ID', userId);

ย const p = find_('PROFIELEN', 'USER_ID', userId) || {};

ย const roles = table_('GEBRUIKER_ROLLEN').filter(r => r.USER_ID === userId && r.STATUS === 'ACTIVE').map(r => r.ROLE_CODE);

ย ย return {

ย ย ย ok: true, version: P12345.VERSION, authenticated: true, sessionToken: rawToken,

ย ย ย user: {

ย ย ย ย ย id: u.ID, email: u.EMAIL, firstName: u.VOORNAAM || '', lastName: u.ACHTERNAAM || '',

ย ย ย ย ย phone: u.TELEFOON || '', displayName: p.WEERGAVENAAM || u.VOORNAAM || u.EMAIL.split('@')[0]

ย ย ย },

ย ย ย roles, interfaces: interfacesFor_(roles)

ย };

}


function interfacesFor_(roles) {

ย const registry = {

ย ย ย SPELER: { icon: '๐ŸŽพ', title: 'Speler Portaal', text: 'Boek lessen, bekijk leerdoelen en neem deel aan de chat.', table: 'BOEKINGEN' },

ย ย ย TRAINER: { icon: '๐Ÿง‘โ€๐Ÿซ', title: 'Trainers Portaal', text: 'Beheer Spelers, Teams, Locaties, Uren, Facturaties & Communicatie.', table: 'TRAINER_BESCHIKBAARHEID' },

ย ย ย ADMIN: { icon: 'โšก', title: 'Administrator Portaal', text: 'Beheer alle tabs, administratie, banen boeken, betalen en plannen.', table: 'PROFIELEN' }

ย };

ย return roles.map(code => Object.assign({ code }, registry[code] || { icon: '๐Ÿ”น', title: code, text: 'PadelOS Module', table: 'PROFIELEN' }));

}


function createUser_(email, first, last) {

ย const now = new Date(), id = id_('GEBR');

ย append_('GEBRUIKERS', { ID: id, EMAIL: email, VOORNAAM: first, ACHTERNAAM: last, TELEFOON: '', PASSWORD_HASH: '', PASSWORD_SALT: '', EMAIL_VERIFIED: 'JA', LAST_LOGIN: '', STATUS: 'ACTIVE', CREATED_AT: now, UPDATED_AT: now });

ย append_('PROFIELEN', { ID: id_('PROF'), USER_ID: id, WEERGAVENAAM: email.split('@')[0], FOTO_URL: '', BIO: '', QUOTE: 'Jij bepaalt je eigen koers.', SPEELSTERKTE: '5', STATUS: 'ACTIVE', CREATED_AT: now, UPDATED_AT: now });

ย ensureUserRole_(id, 'SPELER');

ย return find_('GEBRUIKERS', 'ID', id);

}


function ensureUserRole_(uid, role) {

ย const x = rows_('GEBRUIKER_ROLLEN').find(r => r.data.USER_ID === uid && r.data.ROLE_CODE === role);

ย if (x) return;

ย const n = new Date();

ย append_('GEBRUIKER_ROLLEN', { ID: id_('UROL'), USER_ID: uid, ROLE_CODE: role, STATUS: 'ACTIVE', CREATED_AT: n, UPDATED_AT: n });

}


function seedRole_(code, name, desc) {

ย if (find_('ROLLEN', 'CODE', code)) return;

ย const n = new Date();

ย append_('ROLLEN', { ID: id_('ROLL'), CODE: code, NAAM: name, OMSCHRIJVING: desc, STATUS: 'ACTIVE', CREATED_AT: n, UPDATED_AT: n });

}


function auth_(token, allowExpired) {

ย token = String(token || '');

ย if (token.length < 16) return null;

ย const h = hash_('session|' + token);

ย const x = rows_('SESSIES').find(r => safeEqual_(r.data.TOKEN_HASH, h));

ย if (!x || x.data.STATUS !== 'ACTIVE' || (!allowExpired && dateMs_(x.data.EXPIRES_AT) <= Date.now())) return null;

ย return { userId: x.data.USER_ID, row: x.row };

}


function requireAuth_(t) { const a = auth_(t); if (!a) throw publicError_('Sessie verlopen.'); return a; }

function db_() { return SpreadsheetApp.openById(P12345.SPREADSHEET_ID); }

function ensureTab_(name, headers) {

ย let sh = db_().getSheetByName(name);

ย if (!sh) sh = db_().insertSheet(name);

ย if (sh.getLastRow() === 0 && headers && headers.length > 0) {

ย ย ย sh.getRange(1, 1, 1, headers.length).setValues([headers]);

ย ย ย sh.setFrozenRows(1);

ย ย ย sh.getRange(1, 1, 1, headers.length).setBackground('#0e6b56').setFontColor('#ffffff').setFontWeight('bold');

ย }

ย return sh;

}

function sheet_(n) { return db_().getSheetByName(n) || ensureTab_(n, P12345.TABS[n] || []); }

function headers_(n) { const sh = sheet_(n); return sh.getLastColumn() === 0 ? [] : sh.getRange(1, 1, 1, sh.getLastColumn()).getValues()[0].map(String); }

function rows_(n) {

ย const sh = sheet_(n), v = sh.getDataRange().getValues();

ย if (v.length < 2) return [];

ย const h = v[0].map(String);

ย return v.slice(1).map((r, i) => ({ row: i + 2, data: Object.fromEntries(h.map((k, j) => [k, r[j]])) }));

}

function table_(n) { return rows_(n).map(x => x.data); }

function append_(n, o) { const sh = sheet_(n), h = headers_(n); sh.appendRow(h.map(k => o[k] === undefined ? '' : o[k])); }

function updateRow_(n, row, o) {

ย const sh = sheet_(n), h = headers_(n);

ย Object.entries(o).forEach(([k, v]) => {

ย ย ย let i = h.indexOf(k);

ย ย ย if (i >= 0) sh.getRange(row, i + 1).setValue(v);

ย });

}

function updateBy_(n, k, v, o) { const x = rows_(n).find(r => String(r.data[k]) === String(v)); if (x) updateRow_(n, x.row, o); }

function find_(n, k, v) { return table_(n).find(r => String(r[k]).toLowerCase() === String(v).toLowerCase()); }

function hash_(v) {

ย const secret = PropertiesService.getScriptProperties().getProperty('PADELOS_SECRET') || 'PADELOS';

ย return Utilities.base64EncodeWebSafe(Utilities.computeHmacSha256Signature(String(v), secret)).replace(/=+$/, '');

}

function token_() { return Utilities.base64EncodeWebSafe(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, Utilities.getUuid() + Date.now())).replace(/=+$/, ''); }

function safeEqual_(a, b) { a = String(a || ''); b = String(b || ''); if (a.length !== b.length) return false; let x = 0; for (let i = 0; i < a.length; i++) x |= a.charCodeAt(i) ^ b.charCodeAt(i); return x === 0; }

function id_(p) { return p + '-' + Utilities.getUuid().replace(/-/g, '').slice(0, 16).toUpperCase(); }

function dateMs_(v) { return v instanceof Date ? v.getTime() : new Date(v).getTime() || 0; }

function normalizeEmail_(v) { return String(v || '').trim().toLowerCase(); }

function validEmail_(v) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); }

function clean_(v, max) { return String(v || '').trim().slice(0, max); }

function publicError_(m) { return new Error(m); }