Add shift planning, month closure + payroll, and a dark modern redesign

Ports two modules from the friend's Google Apps Script build (kept as
reference in gscript/) onto the TypeScript stack, rewritten cleanly against
this app's own data model rather than copied 1:1:

- Shift planning: weekly template (Sun-Thu evening, Fri/Sat two slots),
  lazy idempotent generation per period (no cron needed), employee
  signup/cancel with collision + capacity checks, admin calendar view with
  slot editing and manual assignment.
- Month closure + payroll: employee confirms the month (blocked while a
  shift is still open), admin locks and finalizes pay (base hours * rate +
  tips/bonus/other), reopen to undo a premature lock, mark paid. Pay rates
  are versioned by date, defaulting the first-ever rate to apply
  retroactively to the employee's whole history.
- A shift left open more than 12h (forgotten clock-out) is auto-closed at
  clock_in + 12h, checked lazily on read instead of a background job.
- Full dark, sharp-edged modern restyle (theme.css replaces tui.css) with
  an amber accent, keeping every existing class name so no component
  logic needed to change.

Backend test coverage (jest) for all three workflows: shift planning,
closure/payroll, and the forgotten-clock-out auto-close.
This commit is contained in:
Michal Pemcak
2026-08-16 18:27:50 +02:00
parent fffcb73ea4
commit f917ed06a8
64 changed files with 46554 additions and 553 deletions

16
gscript/.clasp.json Normal file
View File

@@ -0,0 +1,16 @@
{
"scriptId": "1O3j68RUOqdnO4N2-sWgv5seES-Bd-AmZDE5HcDjgz0bTN6-jMwNiVrBj",
"rootDir": "",
"scriptExtensions": [
".js",
".gs"
],
"htmlExtensions": [
".html"
],
"jsonExtensions": [
".json"
],
"filePushOrder": [],
"skipSubdirectories": false
}

1659
gscript/AdminService.js Normal file

File diff suppressed because it is too large Load Diff

6
gscript/App.js Normal file
View File

@@ -0,0 +1,6 @@
function doGet() {
return HtmlService.createTemplateFromFile('Index')
.evaluate()
.setTitle(CFG.APP_NAME)
.addMetaTag('viewport','width=device-width, initial-scale=1');
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
function clockAction(token, action) {
const user = requireUser_(token,['EMPLOYEE','MANAGER','ADMIN']);
if (!user.employee_id) throw new Error('Účet není propojený se zaměstnancem.');
return clockForEmployee_(user.employee_id, action, 'PORTAL', user.user_id);
}
function terminalClock(employeeId, pin, action, locationId) {
const emp = findOne_(CFG.SHEETS.EMPLOYEES, r => String(r.employee_id) === String(employeeId));
if (!emp || String(emp.terminal_pin) !== String(pin)) throw new Error('Neplatný PIN.');
if (!(emp.active === true || String(emp.active).toUpperCase() === 'TRUE')) throw new Error('Zaměstnanec není aktivní.');
return clockForEmployee_(employeeId, action, 'TERMINAL', 'TERMINAL:'+String(locationId || emp.location_id));
}
function clockForEmployee_(employeeId, action, source, createdBy) {
const allowed = ['CLOCK_IN','BREAK_START','BREAK_END','CLOCK_OUT'];
if (!allowed.includes(action)) throw new Error('Neplatná akce.');
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
const open = getOpenShift_(employeeId);
const t = now_();
if (action === 'CLOCK_IN') {
if (open) throw new Error('Směna už probíhá.');
const emp = findOne_(CFG.SHEETS.EMPLOYEES, r => String(r.employee_id) === String(employeeId));
const shift = {
shift_id:uuid_('SHIFT'),
employee_id:employeeId,
work_date:isoDate_(t),
clock_in:t,
clock_out:'',
break_minutes:0,
worked_minutes:0,
night_minutes:0,
weekend_minutes:0,
holiday_minutes:0,
status:'OPEN',
location_id:emp ? emp.location_id : '',
created_at:t,
updated_at:t
};
append_(CFG.SHEETS.SHIFTS,shift);
attendanceEvent_(employeeId,t,action,shift.location_id,source,createdBy);
return {ok:true,status:'OPEN',shift};
}
if (!open) throw new Error('Žádná otevřená směna.');
const events = rows_(CFG.SHEETS.ATTENDANCE_EVENTS)
.filter(e => String(e.employee_id) === String(employeeId) && new Date(e.timestamp) >= new Date(open.clock_in))
.sort((a,b) => new Date(a.timestamp)-new Date(b.timestamp));
const last = events[events.length-1];
if (action === 'BREAK_START' && last && String(last.event_type) === 'BREAK_START') throw new Error('Pauza už běží.');
if (action === 'BREAK_END' && (!last || String(last.event_type) !== 'BREAK_START')) throw new Error('Pauza neběží.');
if (action === 'CLOCK_OUT' && last && String(last.event_type) === 'BREAK_START') throw new Error('Nejdřív ukonči pauzu.');
attendanceEvent_(employeeId,t,action,open.location_id,source,createdBy);
if (action === 'CLOCK_OUT') {
const allEvents = events.concat([{timestamp:t,event_type:'CLOCK_OUT'}]);
const calc = calculateShift_(new Date(open.clock_in),t,allEvents);
updateBy_(CFG.SHEETS.SHIFTS,'shift_id',open.shift_id,{
clock_out:t,
break_minutes:calc.breakMinutes,
worked_minutes:calc.workedMinutes,
night_minutes:calc.nightMinutes,
weekend_minutes:calc.weekendMinutes,
status:'COMPLETED',
updated_at:t
});
return {ok:true,status:'COMPLETED',shift_id:open.shift_id,worked_minutes:calc.workedMinutes};
}
return {ok:true,status:action,shift_id:open.shift_id};
} finally {
lock.releaseLock();
}
}
function attendanceEvent_(employeeId,timestamp,type,locationId,source,createdBy) {
append_(CFG.SHEETS.ATTENDANCE_EVENTS,{
event_id:uuid_('EVT'),
employee_id:employeeId,
timestamp,
event_type:type,
location_id:locationId || '',
source:source || '',
created_by:createdBy || ''
});
}
function getOpenShift_(employeeId) {
const list = rows_(CFG.SHEETS.SHIFTS)
.filter(s => String(s.employee_id) === String(employeeId) && String(s.status) === 'OPEN')
.sort((a,b) => new Date(b.clock_in)-new Date(a.clock_in));
return list[0] || null;
}
function calculateShift_(clockIn, clockOut, events) {
let breakMinutes = 0, breakStart = null;
(events || []).forEach(e => {
if (String(e.event_type) === 'BREAK_START') breakStart = new Date(e.timestamp);
if (String(e.event_type) === 'BREAK_END' && breakStart) {
breakMinutes += Math.max(0, Math.round((new Date(e.timestamp)-breakStart)/60000));
breakStart = null;
}
});
const total = Math.max(0, Math.round((clockOut-clockIn)/60000));
const worked = Math.max(0,total-breakMinutes);
// Základní výpočet nočních minut 22:0006:00 po jednotlivých minutách.
let night = 0, weekend = 0;
let p = new Date(clockIn);
while (p < clockOut) {
const n = new Date(Math.min(p.getTime()+60000, clockOut.getTime()));
const hh = Number(Utilities.formatDate(p,CFG.TZ,'H'));
const dow = Number(Utilities.formatDate(p,CFG.TZ,'u'));
if (hh >= 22 || hh < 6) night += (n-p)/60000;
if (dow >= 6) weekend += (n-p)/60000;
p = n;
}
return {
breakMinutes:Math.round(breakMinutes),
workedMinutes:Math.round(worked),
nightMinutes:Math.round(night),
weekendMinutes:Math.round(weekend)
};
}

1104
gscript/AuthService.js Normal file

File diff suppressed because it is too large Load Diff

33
gscript/Automation.js Normal file
View File

@@ -0,0 +1,33 @@
function nightlyMaintenance() {
const cutoff = Date.now() - 14*3600000;
rows_(CFG.SHEETS.SHIFTS)
.filter(s => String(s.status)==='OPEN' && new Date(s.clock_in).getTime() < cutoff)
.forEach(s => {
append_(CFG.SHEETS.NOTIFICATIONS,{
notification_id:uuid_('NOT'),
user_id:'',
type:'OPEN_SHIFT_WARNING',
title:'Pravděpodobně chybí odchod',
message:'Směna '+s.shift_id+' je otevřená déle než 14 hodin.',
url:'',
read_at:'',
created_at:now_(),
email_sent_at:''
});
});
}
function monthlyMaintenance() {
const d = new Date();
d.setMonth(d.getMonth()-1);
const p = Utilities.formatDate(d,CFG.TZ,'yyyy-MM');
rows_(CFG.SHEETS.EMPLOYEES)
.filter(e => e.active === true || String(e.active).toUpperCase()==='TRUE')
.forEach(e => {
const exists = findOne_(CFG.SHEETS.MONTH_CLOSURES,r=>String(r.employee_id)===String(e.employee_id)&&String(r.period)===p);
if (!exists) append_(CFG.SHEETS.MONTH_CLOSURES,{
closure_id:uuid_('CLOSE'),employee_id:e.employee_id,period:p,
employee_confirmed_at:'',manager_approved_at:'',manager_approved_by:'',locked_at:'',status:'WAITING_EMPLOYEE'
});
});
}

2048
gscript/ClosureService.js Normal file

File diff suppressed because it is too large Load Diff

295
gscript/Config.js Normal file
View File

@@ -0,0 +1,295 @@
const CFG = Object.freeze({
APP_NAME: 'EatMe Portál',
TZ: 'Europe/Prague',
SESSION_DAYS: 7,
LOGIN_CODE_MINUTES: 15,
PASSWORD_ROUNDS: 12000,
SHEETS: {
USERS: 'USERS',
EMPLOYEES: 'EMPLOYEES',
COMPANIES: 'COMPANIES',
LOCATIONS: 'LOCATIONS',
USER_COMPANIES: 'USER_COMPANIES',
PAY_RATES: 'PAY_RATES',
ATTENDANCE_EVENTS: 'ATTENDANCE_EVENTS',
SHIFTS: 'SHIFTS',
SHIFT_CHANGE_REQUESTS: 'SHIFT_CHANGE_REQUESTS',
SCHEDULES: 'SCHEDULES',
REQUESTS: 'REQUESTS',
MONTH_CLOSURES: 'MONTH_CLOSURES',
PAYROLL: 'PAYROLL',
PAYROLL_ITEMS: 'PAYROLL_ITEMS',
PAYSLIPS: 'PAYSLIPS',
NEWS: 'NEWS',
NEWS_READS: 'NEWS_READS',
DOCUMENTS: 'DOCUMENTS',
NOTIFICATIONS: 'NOTIFICATIONS',
SESSIONS: 'SESSIONS',
LOGIN_CODES: 'LOGIN_CODES',
AUDIT_LOG: 'AUDIT_LOG',
SETTINGS: 'SETTINGS'
}
});
const HEADERS = Object.freeze({
USERS: [
'user_id',
'email',
'role',
'employee_id',
'password_salt',
'password_hash',
'active',
'created_at',
'last_login'
],
EMPLOYEES: [
'employee_id',
'first_name',
'last_name',
'email',
'phone',
'company_id',
'location_id',
'position',
'employment_type',
'terminal_pin',
'start_date',
'end_date',
'active'
],
COMPANIES: [
'company_id',
'name',
'ico',
'active'
],
LOCATIONS: [
'location_id',
'company_id',
'name',
'active'
],
USER_COMPANIES: [
'user_id',
'company_id'
],
PAY_RATES: [
'pay_rate_id',
'employee_id',
'valid_from',
'valid_to',
'hourly_rate',
'created_by',
'created_at'
],
ATTENDANCE_EVENTS: [
'event_id',
'employee_id',
'timestamp',
'event_type',
'location_id',
'source',
'created_by'
],
SHIFTS: [
'shift_id',
'employee_id',
'work_date',
'clock_in',
'clock_out',
'break_minutes',
'worked_minutes',
'night_minutes',
'weekend_minutes',
'holiday_minutes',
'status',
'location_id',
'created_at',
'updated_at'
],
SHIFT_CHANGE_REQUESTS: [
'request_id',
'shift_id',
'employee_id',
'field',
'old_value',
'requested_value',
'reason',
'status',
'created_at',
'resolved_at',
'resolved_by'
],
SCHEDULES: [
'schedule_id',
'employee_id',
'location_id',
'start_at',
'end_at',
'status',
'note',
'created_by',
'created_at'
],
REQUESTS: [
'request_id',
'employee_id',
'type',
'date_from',
'date_to',
'reason',
'status',
'created_at',
'resolved_at',
'resolved_by',
'note'
],
MONTH_CLOSURES: [
'closure_id',
'employee_id',
'period',
'employee_confirmed_at',
'manager_approved_at',
'manager_approved_by',
'locked_at',
'status'
],
PAYROLL: [
'payroll_id',
'employee_id',
'period',
'approved_minutes',
'base_amount',
'bonus_amount',
'tips_amount',
'other_amount',
'final_amount',
'status',
'payment_date',
'updated_at'
],
PAYROLL_ITEMS: [
'item_id',
'payroll_id',
'employee_id',
'period',
'type',
'quantity',
'rate',
'amount',
'note',
'created_by',
'created_at'
],
PAYSLIPS: [
'payslip_id',
'employee_id',
'period',
'drive_file_id',
'file_name',
'uploaded_at',
'uploaded_by'
],
NEWS: [
'news_id',
'title',
'content',
'category',
'company_id',
'location_id',
'target_role',
'published_from',
'published_to',
'require_confirmation',
'active',
'created_by',
'created_at'
],
NEWS_READS: [
'news_read_id',
'news_id',
'user_id',
'read_at',
'confirmed_at'
],
DOCUMENTS: [
'document_id',
'employee_id',
'company_id',
'type',
'title',
'drive_file_id',
'visible_to_employee',
'uploaded_at',
'uploaded_by'
],
NOTIFICATIONS: [
'notification_id',
'user_id',
'type',
'title',
'message',
'url',
'read_at',
'created_at',
'email_sent_at'
],
SESSIONS: [
'session_id',
'user_id',
'token_hash',
'expires_at',
'created_at',
'last_seen_at'
],
LOGIN_CODES: [
'code_id',
'email',
'purpose',
'code_hash',
'expires_at',
'used_at',
'created_at'
],
AUDIT_LOG: [
'audit_id',
'user_id',
'action',
'entity_type',
'entity_id',
'old_value',
'new_value',
'created_at'
],
SETTINGS: [
'key',
'value'
]
});

3278
gscript/DailySalesService.js Normal file

File diff suppressed because it is too large Load Diff

1099
gscript/DashboardService.js Normal file

File diff suppressed because it is too large Load Diff

72
gscript/Database.js Normal file
View File

@@ -0,0 +1,72 @@
function db_() {
const id = PropertiesService.getScriptProperties().getProperty('DB_SPREADSHEET_ID');
if (!id) throw new Error('Databáze není inicializovaná. Spusť setupSystem().');
return SpreadsheetApp.openById(id);
}
function sh_(name) {
const s = db_().getSheetByName(name);
if (!s) throw new Error('Chybí list ' + name);
return s;
}
function rows_(name) {
const s = sh_(name);
const values = s.getDataRange().getValues();
if (values.length < 2) return [];
const headers = values[0].map(String);
return values.slice(1).filter(r => r.some(v => v !== '')).map(r => {
const o = {};
headers.forEach((h, i) => o[h] = r[i]);
return o;
});
}
function append_(name, obj) {
const s = sh_(name);
const headers = s.getRange(1,1,1,s.getLastColumn()).getValues()[0];
s.appendRow(headers.map(h => obj[h] !== undefined ? obj[h] : ''));
return obj;
}
function updateBy_(name, key, value, patch) {
const s = sh_(name);
const data = s.getDataRange().getValues();
if (!data.length) return false;
const headers = data[0].map(String);
const keyCol = headers.indexOf(key);
if (keyCol < 0) throw new Error('Sloupec ' + key + ' neexistuje v ' + name);
const row = data.slice(1).findIndex(r => String(r[keyCol]) === String(value));
if (row < 0) return false;
Object.keys(patch).forEach(k => {
const c = headers.indexOf(k);
if (c >= 0) s.getRange(row + 2, c + 1).setValue(patch[k]);
});
return true;
}
function findOne_(name, predicate) {
return rows_(name).find(predicate) || null;
}
function uuid_(prefix) {
return (prefix || 'ID') + '_' + Utilities.getUuid().replace(/-/g,'').slice(0,16).toUpperCase();
}
function now_() { return new Date(); }
function isoDate_(d) {
return Utilities.formatDate(new Date(d), CFG.TZ, 'yyyy-MM-dd');
}
function period_(d) {
return Utilities.formatDate(new Date(d), CFG.TZ, 'yyyy-MM');
}
function emailNorm_(email) {
return String(email || '').trim().toLowerCase();
}
function json_(v) {
try { return JSON.stringify(v); } catch(e) { return String(v); }
}

File diff suppressed because it is too large Load Diff

293
gscript/EmployeeService.js Normal file
View File

@@ -0,0 +1,293 @@
function createEmployee(token, data) {
const admin = requireUser_(token,['ADMIN','MANAGER']);
const email = emailNorm_(data.email);
if (!email) throw new Error('E-mail je povinný.');
if (findOne_(CFG.SHEETS.EMPLOYEES, r => emailNorm_(r.email) === email)) {
throw new Error('Zaměstnanec s tímto e-mailem už existuje.');
}
const role = String(data.role || 'EMPLOYEE');
if (!['EMPLOYEE','MANAGER','ACCOUNTANT','ADMIN'].includes(role)) {
throw new Error('Neplatná role.');
}
const hourlyRate = data.hourly_rate === '' || data.hourly_rate === undefined
? ''
: Number(data.hourly_rate);
if (hourlyRate !== '' && (isNaN(hourlyRate) || hourlyRate < 0)) {
throw new Error('Hodinová sazba není platná.');
}
const pin = String(data.terminal_pin || '').trim();
if (pin && !/^\d{4,6}$/.test(pin)) {
throw new Error('PIN musí mít 4 až 6 číslic.');
}
const emp = {
employee_id: uuid_('EMP'),
first_name: String(data.first_name || '').trim(),
last_name: String(data.last_name || '').trim(),
email: email,
phone: String(data.phone || '').trim(),
company_id: String(data.company_id || 'COMPANY_MAIN'),
location_id: String(data.location_id || 'LOCATION_MAIN'),
position: String(data.position || '').trim(),
employment_type: String(data.employment_type || '').trim(),
terminal_pin: pin,
start_date: data.start_date || isoDate_(now_()),
end_date: '',
active: true
};
if (!emp.first_name || !emp.last_name) {
throw new Error('Jméno a příjmení jsou povinné.');
}
append_(CFG.SHEETS.EMPLOYEES, emp);
const user = {
user_id: uuid_('USR'),
email: email,
role: role,
employee_id: emp.employee_id,
password_salt: '',
password_hash: '',
active: true,
created_at: now_(),
last_login: ''
};
append_(CFG.SHEETS.USERS, user);
append_(CFG.SHEETS.USER_COMPANIES, {
user_id: user.user_id,
company_id: emp.company_id
});
if (hourlyRate !== '') {
append_(CFG.SHEETS.PAY_RATES, {
pay_rate_id: uuid_('RATE'),
employee_id: emp.employee_id,
valid_from: emp.start_date,
valid_to: '',
hourly_rate: hourlyRate,
created_by: admin.user_id,
created_at: now_()
});
}
audit_(admin.user_id,'EMPLOYEE_CREATED','EMPLOYEE',emp.employee_id,'',emp);
requestActivation(email);
return {
ok: true,
employee_id: emp.employee_id
};
}
function listEmployees(token) {
requireUser_(
token,
['ADMIN','MANAGER','ACCOUNTANT']
);
const employees =
rows_(CFG.SHEETS.EMPLOYEES);
const users =
rows_(CFG.SHEETS.USERS);
const rates =
rows_(CFG.SHEETS.PAY_RATES);
return employees.map(function(employee) {
const user =
users.find(function(u) {
return String(u.employee_id) ===
String(employee.employee_id);
});
const employeeRates =
rates.filter(function(rate) {
return String(rate.employee_id) ===
String(employee.employee_id);
});
employeeRates.sort(function(a,b) {
const dateA =
a.valid_from
? new Date(a.valid_from).getTime()
: 0;
const dateB =
b.valid_from
? new Date(b.valid_from).getTime()
: 0;
return dateB - dateA;
});
let hourlyRate = 0;
if (employeeRates.length > 0) {
hourlyRate =
Number(
employeeRates[0].hourly_rate || 0
);
}
const active =
employee.active === true ||
String(employee.active)
.toUpperCase() === 'TRUE';
return {
employee_id:
String(employee.employee_id || ''),
first_name:
String(employee.first_name || ''),
last_name:
String(employee.last_name || ''),
name:
(
String(employee.first_name || '') +
' ' +
String(employee.last_name || '')
).trim(),
email:
String(employee.email || ''),
phone:
String(employee.phone || ''),
company_id:
String(employee.company_id || ''),
location_id:
String(employee.location_id || ''),
position:
String(employee.position || ''),
employment_type:
String(employee.employment_type || ''),
terminal_pin:
String(employee.terminal_pin || ''),
start_date:
employee.start_date
? String(employee.start_date)
: '',
end_date:
employee.end_date
? String(employee.end_date)
: '',
active:
active,
role:
user
? String(user.role || 'EMPLOYEE')
: 'EMPLOYEE',
hourly_rate:
hourlyRate
};
});
}
function setEmployeeActive(token, employeeId, active) {
const admin = requireUser_(token,['ADMIN']);
const emp = findOne_(
CFG.SHEETS.EMPLOYEES,
r => String(r.employee_id) === String(employeeId)
);
if (!emp) throw new Error('Zaměstnanec nebyl nalezen.');
const user = findOne_(
CFG.SHEETS.USERS,
r => String(r.employee_id) === String(employeeId)
);
updateBy_(
CFG.SHEETS.EMPLOYEES,
'employee_id',
employeeId,
{
active: !!active,
end_date: active ? '' : isoDate_(now_())
}
);
if (user) {
updateBy_(
CFG.SHEETS.USERS,
'user_id',
user.user_id,
{ active: !!active }
);
}
audit_(
admin.user_id,
'EMPLOYEE_ACTIVE_CHANGED',
'EMPLOYEE',
employeeId,
emp.active,
!!active
);
return { ok: true };
}
function getCompaniesAndLocations(token) {
requireUser_(token,['ADMIN','MANAGER']);
return {
companies: rows_(CFG.SHEETS.COMPANIES)
.filter(x =>
x.active === true ||
String(x.active).toUpperCase() === 'TRUE'
),
locations: rows_(CFG.SHEETS.LOCATIONS)
.filter(x =>
x.active === true ||
String(x.active).toUpperCase() === 'TRUE'
)
};
}

18711
gscript/Index.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,15 @@
/* ============================================================
EATME PORTÁL LOGIN PERFORMANCE SERVICE
============================================================ */
function loginWithUser(
email,
password
) {
return login(
email,
password
);
}

55
gscript/NewsService.js Normal file
View File

@@ -0,0 +1,55 @@
function getNewsForUser_(user, emp) {
const now = now_();
const memberships = rows_(CFG.SHEETS.USER_COMPANIES)
.filter(x => String(x.user_id) === String(user.user_id))
.map(x => String(x.company_id));
return rows_(CFG.SHEETS.NEWS)
.filter(n => n.active === true || String(n.active).toUpperCase() === 'TRUE')
.filter(n => !n.published_from || new Date(n.published_from) <= now)
.filter(n => !n.published_to || new Date(n.published_to) >= now)
.filter(n => !n.target_role || String(n.target_role) === 'ALL' || String(n.target_role) === String(user.role))
.filter(n => !n.company_id || memberships.includes(String(n.company_id)) || (emp && String(emp.company_id) === String(n.company_id)))
.filter(n => !n.location_id || (emp && String(emp.location_id) === String(n.location_id)))
.sort((a,b)=>new Date(b.created_at)-new Date(a.created_at))
.map(n => ({
news_id:n.news_id,title:n.title,content:n.content,category:n.category,
require_confirmation:n.require_confirmation,created_at:n.created_at
}));
}
function publishNews(token, data) {
const user = requireUser_(token,['ADMIN','MANAGER']);
const n = {
news_id:uuid_('NEWS'),
title:String(data.title || '').trim(),
content:String(data.content || '').trim(),
category:String(data.category || 'INFO'),
company_id:String(data.company_id || ''),
location_id:String(data.location_id || ''),
target_role:String(data.target_role || 'ALL'),
published_from:data.published_from || now_(),
published_to:data.published_to || '',
require_confirmation:!!data.require_confirmation,
active:true,
created_by:user.user_id,
created_at:now_()
};
if (!n.title) throw new Error('Titulek je povinný.');
append_(CFG.SHEETS.NEWS,n);
audit_(user.user_id,'NEWS_PUBLISHED','NEWS',n.news_id,'',n);
return {ok:true,news_id:n.news_id};
}
function confirmNews(token, newsId) {
const user = requireUser_(token);
let r = findOne_(CFG.SHEETS.NEWS_READS, x => String(x.news_id)===String(newsId) && String(x.user_id)===String(user.user_id));
if (r) {
updateBy_(CFG.SHEETS.NEWS_READS,'news_read_id',r.news_read_id,{read_at:r.read_at || now_(),confirmed_at:now_()});
} else {
append_(CFG.SHEETS.NEWS_READS,{
news_read_id:uuid_('NREAD'),news_id:newsId,user_id:user.user_id,read_at:now_(),confirmed_at:now_()
});
}
return {ok:true};
}

1575
gscript/NewsService_Roles.js Normal file

File diff suppressed because it is too large Load Diff

1554
gscript/PayrollService.js Normal file

File diff suppressed because it is too large Load Diff

377
gscript/Security.js Normal file
View File

@@ -0,0 +1,377 @@
/* ============================================================
EATME PORTÁL SECURITY HELPERS
============================================================ */
/* ============================================================
NÁHODNÝ AKTIVAČNÍ KÓD
============================================================ */
function randomCode_() {
return String(
Math.floor(
100000 +
Math.random() *
900000
)
);
}
/* ============================================================
SESSION TOKEN
============================================================ */
function randomToken_() {
return Utilities
.base64EncodeWebSafe(
Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
Utilities.getUuid() +
':' +
new Date().getTime() +
':' +
Math.random()
)
)
.replace(
/=+$/,
''
);
}
/* ============================================================
SHA-256
============================================================ */
function hashText_(text) {
const bytes =
Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
String(
text
)
);
return bytes
.map(
function(byte) {
return (
'0' +
(
(
byte < 0
? byte + 256
: byte
)
.toString(
16
)
)
).slice(
-2
);
}
)
.join(
''
);
}
/* ============================================================
PASSWORD HASH
POZOR:
Toto je původní algoritmus.
Neměníme jej, aby fungovala současná hesla.
============================================================ */
function passwordHash_(
password,
salt
) {
const pepper =
PropertiesService
.getScriptProperties()
.getProperty(
'PASSWORD_PEPPER'
) ||
'';
let value =
String(
password
) +
'|' +
salt +
'|' +
pepper;
for (
let i = 0;
i < CFG.PASSWORD_ROUNDS;
i++
) {
value =
hashText_(
value +
'|' +
i
);
}
return value;
}
/* ============================================================
VALIDACE HESLA
============================================================ */
function validatePassword_(password) {
const value =
String(
password ||
''
);
if (
value.length <
10
) {
throw new Error(
'Heslo musí mít alespoň 10 znaků.'
);
}
if (
!/[A-Za-z]/.test(
value
) ||
!/[0-9]/.test(
value
)
) {
throw new Error(
'Heslo musí obsahovat písmeno a číslo.'
);
}
}
/* ============================================================
OVĚŘENÍ UŽIVATELE
Optimalizace:
už při každém requestu NEZAPISUJEME last_seen_at.
============================================================ */
function requireUser_(
token,
roles
) {
const tokenHash =
hashText_(
String(
token ||
''
)
);
const session =
findOne_(
CFG.SHEETS.SESSIONS,
function(row) {
return (
String(
row.token_hash
) ===
tokenHash &&
new Date(
row.expires_at
).getTime() >
Date.now()
);
}
);
if (
!session
) {
throw new Error(
'Přihlášení vypršelo. Přihlas se znovu.'
);
}
const user =
findOne_(
CFG.SHEETS.USERS,
function(row) {
return (
String(
row.user_id
) ===
String(
session.user_id
)
);
}
);
if (
!user ||
!(
user.active ===
true ||
String(
user.active
).toUpperCase() ===
'TRUE'
)
) {
throw new Error(
'Účet není aktivní.'
);
}
if (
roles &&
roles.length &&
!roles.includes(
String(
user.role
)
)
) {
throw new Error(
'Nemáš oprávnění.'
);
}
/*
* PŮVODNĚ ZDE BYLO:
*
* updateBy_(
* CFG.SHEETS.SESSIONS,
* 'session_id',
* session.session_id,
* {last_seen_at:now_()}
* );
*
* To způsobovalo zápis do Google Sheets při téměř
* každém kliknutí v aplikaci.
*/
return user;
}
/* ============================================================
AUDIT
============================================================ */
function audit_(
userId,
action,
entityType,
entityId,
oldValue,
newValue
) {
append_(
CFG.SHEETS.AUDIT_LOG,
{
audit_id:
uuid_(
'AUD'
),
user_id:
userId ||
'SYSTEM',
action:
action,
entity_type:
entityType ||
'',
entity_id:
entityId ||
'',
old_value:
json_(
oldValue
),
new_value:
json_(
newValue
),
created_at:
now_()
}
);
}

128
gscript/Setup.js Normal file
View File

@@ -0,0 +1,128 @@
function setupSystem() {
const props = PropertiesService.getScriptProperties();
let id = props.getProperty('DB_SPREADSHEET_ID');
let ss;
if (id) {
ss = SpreadsheetApp.openById(id);
} else {
ss = SpreadsheetApp.create('MeStaff Database');
props.setProperty('DB_SPREADSHEET_ID', ss.getId());
}
Object.keys(CFG.SHEETS).forEach(k => {
const name = CFG.SHEETS[k];
let s = ss.getSheetByName(name);
if (!s) s = ss.insertSheet(name);
const headers = HEADERS[k];
if (s.getLastRow() === 0) s.getRange(1,1,1,headers.length).setValues([headers]);
s.setFrozenRows(1);
s.getRange(1,1,1,headers.length).setFontWeight('bold');
});
const defaultSheet = ss.getSheetByName('Sheet1') || ss.getSheetByName('List1');
if (defaultSheet && Object.values(CFG.SHEETS).indexOf(defaultSheet.getName()) === -1) {
ss.deleteSheet(defaultSheet);
}
if (!props.getProperty('PASSWORD_PEPPER')) {
props.setProperty('PASSWORD_PEPPER', Utilities.getUuid() + Utilities.getUuid());
}
seedSettings_();
return {ok:true, spreadsheetId:ss.getId(), url:ss.getUrl()};
}
function seedSettings_() {
const current = rows_(CFG.SHEETS.SETTINGS);
const map = {};
current.forEach(r => map[r.key] = r.value);
const defaults = {
APP_NAME: 'MeStaff',
CURRENCY: 'CZK',
TIMEZONE: CFG.TZ,
NIGHT_START: '22:00',
NIGHT_END: '06:00'
};
Object.keys(defaults).forEach(k => {
if (map[k] === undefined) append_(CFG.SHEETS.SETTINGS, {key:k, value:defaults[k]});
});
}
function seedFirstAdmin(email, firstName, lastName) {
email = emailNorm_(email);
if (!email) throw new Error('Zadej e-mail.');
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
let company = findOne_(CFG.SHEETS.COMPANIES, r => r.active === true || String(r.active).toUpperCase() === 'TRUE');
if (!company) {
company = {company_id:'COMPANY_MAIN', name:'Hlavní společnost', ico:'', active:true};
append_(CFG.SHEETS.COMPANIES, company);
}
let loc = findOne_(CFG.SHEETS.LOCATIONS, r => String(r.company_id) === String(company.company_id));
if (!loc) {
loc = {location_id:'LOCATION_MAIN', company_id:company.company_id, name:'Hlavní provozovna', active:true};
append_(CFG.SHEETS.LOCATIONS, loc);
}
let emp = findOne_(CFG.SHEETS.EMPLOYEES, r => emailNorm_(r.email) === email);
if (!emp) {
emp = {
employee_id: uuid_('EMP'),
first_name:firstName || 'Admin',
last_name:lastName || '',
email,
company_id:company.company_id,
location_id:loc.location_id,
position:'Administrátor',
employment_type:'',
terminal_pin:'',
start_date:isoDate_(now_()),
end_date:'',
active:true
};
append_(CFG.SHEETS.EMPLOYEES, emp);
}
let user = findOne_(CFG.SHEETS.USERS, r => emailNorm_(r.email) === email);
if (!user) {
user = {
user_id:uuid_('USR'),
email,
role:'ADMIN',
employee_id:emp.employee_id,
password_salt:'',
password_hash:'',
active:true,
created_at:now_(),
last_login:''
};
append_(CFG.SHEETS.USERS, user);
}
return {ok:true, email, message:'Admin založen. Teď použij „Aktivovat účet“ na přihlašovací stránce.'};
} finally {
lock.releaseLock();
}
}
function createSystemTriggers() {
ScriptApp.getProjectTriggers().forEach(t => {
if (['nightlyMaintenance','monthlyMaintenance'].includes(t.getHandlerFunction())) {
ScriptApp.deleteTrigger(t);
}
});
ScriptApp.newTrigger('nightlyMaintenance')
.timeBased().everyDays(1).atHour(3).inTimezone(CFG.TZ).create();
ScriptApp.newTrigger('monthlyMaintenance')
.timeBased().onMonthDay(1).atHour(4).inTimezone(CFG.TZ).create();
return {ok:true};
}
function createMyAdmin() {
return seedFirstAdmin(
'filip.thurrigl@gmail.com',
'Philipp',
'Thürrigl'
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

10
gscript/appsscript.json Normal file
View File

@@ -0,0 +1,10 @@
{
"timeZone": "Europe/Prague",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"webapp": {
"executeAs": "USER_DEPLOYING",
"access": "ANYONE_ANONYMOUS"
}
}