/************************************************************
* 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); }