feat: add company sidebar filters, internal phone, mail icon fix, and update script

This commit is contained in:
2026-05-26 09:08:34 +07:00
parent 7e11d42bbb
commit fc4a283c0a
10 changed files with 250 additions and 27 deletions
+9
View File
@@ -78,11 +78,20 @@ async function initializeTables() {
email TEXT, email TEXT,
building TEXT, building TEXT,
floor TEXT, floor TEXT,
internal_phone TEXT,
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL, FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL
) )
`); `);
// Dynamic schema migration: add internal_phone column if it does not exist
const columns = await all('PRAGMA table_info(employees)');
const hasInternalPhone = columns.some((col) => col.name === 'internal_phone');
if (!hasInternalPhone) {
console.log('Migrating database: adding internal_phone column to employees table...');
await run('ALTER TABLE employees ADD COLUMN internal_phone TEXT');
}
// Seed data if database is fresh // Seed data if database is fresh
const deptCount = await get('SELECT COUNT(*) as count FROM departments'); const deptCount = await get('SELECT COUNT(*) as count FROM departments');
if (deptCount.count === 0) { if (deptCount.count === 0) {
+28 -11
View File
@@ -190,7 +190,7 @@ app.put('/api/companies/:id', authenticateToken, async (req, res) => {
// 4. Employees Endpoints (Search & List) // 4. Employees Endpoints (Search & List)
app.get('/api/employees', async (req, res) => { app.get('/api/employees', async (req, res) => {
const { search, departmentId } = req.query; const { search, departmentId, companyId } = req.query;
let sql = ` let sql = `
SELECT e.*, d.name as department_name, c.name as company_name SELECT e.*, d.name as department_name, c.name as company_name
@@ -206,6 +206,11 @@ app.get('/api/employees', async (req, res) => {
params.push(departmentId); params.push(departmentId);
} }
if (companyId) {
conditions.push('e.company_id = ?');
params.push(companyId);
}
if (conditions.length > 0) { if (conditions.length > 0) {
sql += ' WHERE ' + conditions.join(' AND '); sql += ' WHERE ' + conditions.join(' AND ');
} }
@@ -237,6 +242,7 @@ app.get('/api/employees', async (req, res) => {
const compMatch = e.company_name && e.company_name.toLowerCase().includes(cleanSearch); const compMatch = e.company_name && e.company_name.toLowerCase().includes(cleanSearch);
const buildMatch = e.building && e.building.toLowerCase().includes(cleanSearch); const buildMatch = e.building && e.building.toLowerCase().includes(cleanSearch);
const floorMatch = e.floor && e.floor.toLowerCase().includes(cleanSearch); const floorMatch = e.floor && e.floor.toLowerCase().includes(cleanSearch);
const internalPhoneMatch = e.internal_phone && e.internal_phone.includes(cleanSearch);
let phoneMatch = false; let phoneMatch = false;
if (searchDigits.length > 0 && e.phone) { if (searchDigits.length > 0 && e.phone) {
@@ -246,7 +252,7 @@ app.get('/api/employees', async (req, res) => {
} }
} }
return nameMatch || jobMatch || emailMatch || deptMatch || compMatch || buildMatch || floorMatch || phoneMatch; return nameMatch || jobMatch || emailMatch || deptMatch || compMatch || buildMatch || floorMatch || phoneMatch || internalPhoneMatch;
}); });
} }
@@ -258,16 +264,20 @@ app.get('/api/employees', async (req, res) => {
// Create Employee // Create Employee
app.post('/api/employees', authenticateToken, async (req, res) => { app.post('/api/employees', authenticateToken, async (req, res) => {
const { name, company_id, job_title, department_id, phone, email, building, floor } = req.body; const { name, company_id, job_title, department_id, phone, email, building, floor, internal_phone } = req.body;
if (!name || !job_title) { if (!name || !job_title) {
return res.status(400).json({ error: 'Name and Job Title are required fields.' }); return res.status(400).json({ error: 'Name and Job Title are required fields.' });
} }
if (internal_phone && !/^\d{3}$/.test(internal_phone.trim())) {
return res.status(400).json({ error: 'Внутренний телефон должен состоять ровно из 3 цифр.' });
}
try { try {
const result = await db.run(` const result = await db.run(`
INSERT INTO employees (name, company_id, job_title, department_id, phone, email, building, floor) INSERT INTO employees (name, company_id, job_title, department_id, phone, email, building, floor, internal_phone)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [ `, [
name.trim(), name.trim(),
company_id || null, company_id || null,
@@ -276,7 +286,8 @@ app.post('/api/employees', authenticateToken, async (req, res) => {
phone ? phone.trim() : '', phone ? phone.trim() : '',
email ? email.trim() : '', email ? email.trim() : '',
building ? building.trim() : '', building ? building.trim() : '',
floor ? floor.trim() : '' floor ? floor.trim() : '',
internal_phone ? internal_phone.trim() : ''
]); ]);
const newEmp = await db.get(` const newEmp = await db.get(`
@@ -296,16 +307,20 @@ app.post('/api/employees', authenticateToken, async (req, res) => {
// Update Employee // Update Employee
app.put('/api/employees/:id', authenticateToken, async (req, res) => { app.put('/api/employees/:id', authenticateToken, async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { name, company_id, job_title, department_id, phone, email, building, floor } = req.body; const { name, company_id, job_title, department_id, phone, email, building, floor, internal_phone } = req.body;
if (!name || !job_title) { if (!name || !job_title) {
return res.status(400).json({ error: 'Name and Job Title are required fields.' }); return res.status(400).json({ error: 'Name and Job Title are required fields.' });
} }
if (internal_phone && !/^\d{3}$/.test(internal_phone.trim())) {
return res.status(400).json({ error: 'Внутренний телефон должен состоять ровно из 3 цифр.' });
}
try { try {
await db.run(` await db.run(`
UPDATE employees UPDATE employees
SET name = ?, company_id = ?, job_title = ?, department_id = ?, phone = ?, email = ?, building = ?, floor = ? SET name = ?, company_id = ?, job_title = ?, department_id = ?, phone = ?, email = ?, building = ?, floor = ?, internal_phone = ?
WHERE id = ? WHERE id = ?
`, [ `, [
name.trim(), name.trim(),
@@ -316,6 +331,7 @@ app.put('/api/employees/:id', authenticateToken, async (req, res) => {
email ? email.trim() : '', email ? email.trim() : '',
building ? building.trim() : '', building ? building.trim() : '',
floor ? floor.trim() : '', floor ? floor.trim() : '',
internal_phone ? internal_phone.trim() : '',
id id
]); ]);
@@ -393,8 +409,8 @@ app.post('/api/admin/import', authenticateToken, async (req, res) => {
const newDeptId = e.department_id ? (deptIdMapping[e.department_id] || null) : null; const newDeptId = e.department_id ? (deptIdMapping[e.department_id] || null) : null;
const newCompId = e.company_id ? (compIdMapping[e.company_id] || null) : null; const newCompId = e.company_id ? (compIdMapping[e.company_id] || null) : null;
await db.run(` await db.run(`
INSERT INTO employees (name, company_id, job_title, department_id, phone, email, building, floor) INSERT INTO employees (name, company_id, job_title, department_id, phone, email, building, floor, internal_phone)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [ `, [
e.name, e.name,
newCompId, newCompId,
@@ -403,7 +419,8 @@ app.post('/api/admin/import', authenticateToken, async (req, res) => {
e.phone || '', e.phone || '',
e.email || '', e.email || '',
e.building || '', e.building || '',
e.floor || '' e.floor || '',
e.internal_phone || ''
]); ]);
} }
+15 -3
View File
@@ -34,6 +34,7 @@ const App = () => {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedDept, setSelectedDept] = useState(null); const [selectedDept, setSelectedDept] = useState(null);
const [selectedCompany, setSelectedCompany] = useState(null);
const [activeView, setActiveView] = useState('directory'); // 'directory', 'login', 'admin' const [activeView, setActiveView] = useState('directory'); // 'directory', 'login', 'admin'
const [selectedEmployee, setSelectedEmployee] = useState(null); const [selectedEmployee, setSelectedEmployee] = useState(null);
@@ -117,10 +118,13 @@ const App = () => {
// Client-side search and filtering // Client-side search and filtering
const filteredEmployees = employees.filter((emp) => { const filteredEmployees = employees.filter((emp) => {
// 1. Department filter // 1. Company filter
if (selectedCompany && emp.company_id !== selectedCompany) return false;
// 2. Department filter
if (selectedDept && emp.department_id !== selectedDept) return false; if (selectedDept && emp.department_id !== selectedDept) return false;
// 2. Search query filter (Case-insensitive, fragments, normalized phone) // 3. Search query filter (Case-insensitive, fragments, normalized phone)
if (searchQuery.trim()) { if (searchQuery.trim()) {
const queryClean = searchQuery.toLowerCase().trim(); const queryClean = searchQuery.toLowerCase().trim();
const queryDigits = getQueryPhoneDigits(queryClean); const queryDigits = getQueryPhoneDigits(queryClean);
@@ -132,13 +136,18 @@ const App = () => {
const empPhoneDigits = getPhoneDigits(emp.phone); const empPhoneDigits = getPhoneDigits(emp.phone);
const phoneMatch = queryDigits.length > 0 && empPhoneDigits.includes(queryDigits); const phoneMatch = queryDigits.length > 0 && empPhoneDigits.includes(queryDigits);
// Match internal phone
const empInternalPhoneDigits = getPhoneDigits(emp.internal_phone);
const internalPhoneMatch = queryDigits.length > 0 && empInternalPhoneDigits.includes(queryDigits);
const internalPhoneStringMatch = emp.internal_phone && emp.internal_phone.toLowerCase().includes(queryClean);
// Secondary match parameters (Job title, Company, Location) // Secondary match parameters (Job title, Company, Location)
const titleMatch = emp.job_title.toLowerCase().includes(queryClean); const titleMatch = emp.job_title.toLowerCase().includes(queryClean);
const companyMatch = emp.company_name && emp.company_name.toLowerCase().includes(queryClean); const companyMatch = emp.company_name && emp.company_name.toLowerCase().includes(queryClean);
const deptMatch = emp.department_name && emp.department_name.toLowerCase().includes(queryClean); const deptMatch = emp.department_name && emp.department_name.toLowerCase().includes(queryClean);
const buildingMatch = emp.building && emp.building.toLowerCase().includes(queryClean); const buildingMatch = emp.building && emp.building.toLowerCase().includes(queryClean);
return nameMatch || phoneMatch || titleMatch || companyMatch || deptMatch || buildingMatch; return nameMatch || phoneMatch || internalPhoneMatch || internalPhoneStringMatch || titleMatch || companyMatch || deptMatch || buildingMatch;
} }
return true; return true;
@@ -203,6 +212,9 @@ const App = () => {
{activeView === 'directory' && ( {activeView === 'directory' && (
<div className="directory-layout"> <div className="directory-layout">
<Sidebar <Sidebar
companies={companies}
selectedCompany={selectedCompany}
setSelectedCompany={setSelectedCompany}
departments={departments} departments={departments}
selectedDept={selectedDept} selectedDept={selectedDept}
setSelectedDept={setSelectedDept} setSelectedDept={setSelectedDept}
+26 -3
View File
@@ -16,7 +16,8 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
phone: '', phone: '',
email: '', email: '',
building: '', building: '',
floor: '' floor: '',
internal_phone: ''
}); });
// Department form state // Department form state
@@ -215,7 +216,8 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
phone: '', phone: '',
email: '', email: '',
building: '', building: '',
floor: '' floor: '',
internal_phone: ''
}); });
setShowEmpModal(true); setShowEmpModal(true);
}; };
@@ -230,7 +232,8 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
phone: emp.phone || '', phone: emp.phone || '',
email: emp.email || '', email: emp.email || '',
building: emp.building || '', building: emp.building || '',
floor: emp.floor || '' floor: emp.floor || '',
internal_phone: emp.internal_phone || ''
}); });
setShowEmpModal(true); setShowEmpModal(true);
}; };
@@ -242,6 +245,11 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
return; return;
} }
if (empForm.internal_phone && !/^\d{3}$/.test(empForm.internal_phone.trim())) {
showAlert('error', 'Внутренний телефон должен состоять ровно из 3 цифр!');
return;
}
try { try {
const employeePayload = { const employeePayload = {
...empForm, ...empForm,
@@ -408,6 +416,7 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
<td> <td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.15rem', fontSize: '0.8rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.15rem', fontSize: '0.8rem' }}>
{emp.phone && <div>📞 {emp.phone}</div>} {emp.phone && <div>📞 {emp.phone}</div>}
{emp.internal_phone && <div>📞 Вн.: {emp.internal_phone}</div>}
{emp.email && <div> {emp.email}</div>} {emp.email && <div> {emp.email}</div>}
</div> </div>
</td> </td>
@@ -751,6 +760,20 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
/> />
</div> </div>
<div className="form-group">
<label className="form-label">Внутренний телефон</label>
<input
type="text"
className="form-control"
placeholder="123"
maxLength={3}
pattern="^[0-9]{3}$|^$"
title="Внутренний телефон должен состоять ровно из 3 цифр"
value={empForm.internal_phone}
onChange={(e) => setEmpForm({ ...empForm, internal_phone: e.target.value })}
/>
</div>
<div className="form-group"> <div className="form-group">
<label className="form-label">Электронная почта (Email)</label> <label className="form-label">Электронная почта (Email)</label>
<input <input
+7 -1
View File
@@ -4,7 +4,7 @@ import { CloseIcon, PhoneIcon, EmailIcon, OfficeIcon, DownloadIcon } from '../ut
const DetailModal = ({ employee, onClose }) => { const DetailModal = ({ employee, onClose }) => {
if (!employee) return null; if (!employee) return null;
const { name, company_name, job_title, department_name, phone, email, building, floor } = employee; const { name, company_name, job_title, department_name, phone, email, building, floor, internal_phone } = employee;
// Generate and download vCard (VCF) // Generate and download vCard (VCF)
const handleDownloadVCard = () => { const handleDownloadVCard = () => {
@@ -19,6 +19,7 @@ const DetailModal = ({ employee, onClose }) => {
const noteParts = []; const noteParts = [];
if (building) noteParts.push(`Здание: ${building}`); if (building) noteParts.push(`Здание: ${building}`);
if (floor) noteParts.push(`Этаж: ${floor}`); if (floor) noteParts.push(`Этаж: ${floor}`);
if (internal_phone) noteParts.push(`Внутренний телефон: ${internal_phone}`);
const vCardLines = [ const vCardLines = [
'BEGIN:VCARD', 'BEGIN:VCARD',
@@ -83,6 +84,11 @@ const DetailModal = ({ employee, onClose }) => {
</span> </span>
</div> </div>
<div className="detail-block">
<span className="detail-label">Внутренний телефон</span>
<span className="detail-value">{internal_phone || 'Не указан'}</span>
</div>
<div className="detail-block"> <div className="detail-block">
<span className="detail-label">Здание</span> <span className="detail-label">Здание</span>
<span className="detail-value" style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}> <span className="detail-value" style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}>
+9 -1
View File
@@ -17,13 +17,14 @@ const EmployeeTable = ({ employees, onEmployeeClick }) => {
<th>Должность</th> <th>Должность</th>
<th>Размещение</th> <th>Размещение</th>
<th>Телефон</th> <th>Телефон</th>
<th>Вн. тел.</th>
<th>Email</th> <th>Email</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{employees.length === 0 ? ( {employees.length === 0 ? (
<tr> <tr>
<td colSpan="7" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}> <td colSpan="8" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>
Сотрудники не найдены Сотрудники не найдены
</td> </td>
</tr> </tr>
@@ -66,6 +67,13 @@ const EmployeeTable = ({ employees, onEmployeeClick }) => {
<span style={{ color: 'var(--text-muted)' }}></span> <span style={{ color: 'var(--text-muted)' }}></span>
)} )}
</td> </td>
<td>
{emp.internal_phone ? (
<span style={{ fontWeight: 600 }}>{emp.internal_phone}</span>
) : (
<span style={{ color: 'var(--text-muted)' }}></span>
)}
</td>
<td> <td>
{emp.email ? ( {emp.email ? (
<a <a
+8 -5
View File
@@ -56,7 +56,7 @@ const ImportExport = ({ token, onRefreshData }) => {
throw new Error('CSV файл пуст или содержит недостаточно строк.'); throw new Error('CSV файл пуст или содержит недостаточно строк.');
} }
// Expected Headers: ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж // Expected Headers: ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж;Внутренний телефон
// Parse semicolon separated columns // Parse semicolon separated columns
const employees = []; const employees = [];
const departmentsSet = new Set(); const departmentsSet = new Set();
@@ -74,7 +74,8 @@ const ImportExport = ({ token, onRefreshData }) => {
phone: values[4] || '', phone: values[4] || '',
email: values[5] || '', email: values[5] || '',
building: values[6] || '', building: values[6] || '',
floor: values[7] || '' floor: values[7] || '',
internal_phone: values[8] || ''
}; };
if (emp.name && emp.job_title) { if (emp.name && emp.job_title) {
@@ -108,6 +109,7 @@ const ImportExport = ({ token, onRefreshData }) => {
email: emp.email, email: emp.email,
building: emp.building, building: emp.building,
floor: emp.floor, floor: emp.floor,
internal_phone: emp.internal_phone,
department_id: dept ? dept.id : null, department_id: dept ? dept.id : null,
company_id: comp ? comp.id : null company_id: comp ? comp.id : null
}; };
@@ -174,7 +176,7 @@ const ImportExport = ({ token, onRefreshData }) => {
if (!res.ok) throw new Error('Ошибка экспорта данных с сервера'); if (!res.ok) throw new Error('Ошибка экспорта данных с сервера');
const data = await res.json(); const data = await res.json();
const headers = ['ФИО', 'Компания', 'Отдел', 'Должность', 'Телефон', 'Email', 'Здание', 'Этаж']; const headers = ['ФИО', 'Компания', 'Отдел', 'Должность', 'Телефон', 'Email', 'Здание', 'Этаж', 'Внутренний телефон'];
// Semicolon separator // Semicolon separator
const csvRows = [headers.join(';')]; const csvRows = [headers.join(';')];
@@ -189,7 +191,8 @@ const ImportExport = ({ token, onRefreshData }) => {
`"${(emp.phone || '').replace(/"/g, '""')}"`, `"${(emp.phone || '').replace(/"/g, '""')}"`,
`"${(emp.email || '').replace(/"/g, '""')}"`, `"${(emp.email || '').replace(/"/g, '""')}"`,
`"${(emp.building || '').replace(/"/g, '""')}"`, `"${(emp.building || '').replace(/"/g, '""')}"`,
`"${(emp.floor || '').replace(/"/g, '""')}"` `"${(emp.floor || '').replace(/"/g, '""')}"`,
`"${(emp.internal_phone || '').replace(/"/g, '""')}"`
]; ];
csvRows.push(row.join(';')); csvRows.push(row.join(';'));
} }
@@ -246,7 +249,7 @@ const ImportExport = ({ token, onRefreshData }) => {
</div> </div>
</form> </form>
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.75rem' }}> <p style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.75rem' }}>
* При импорте текущая база будет очищена и перезаписана! Структура колонок CSV (разделитель - <strong>точка с запятой ";"</strong>): ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж. Кодировка UTF-8. * При импорте текущая база будет очищена и перезаписана! Структура колонок CSV (разделитель - <strong>точка с запятой ";"</strong>): ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж;Внутренний телефон. Кодировка UTF-8.
</p> </p>
</div> </div>
+46 -2
View File
@@ -1,6 +1,20 @@
import React from 'react'; import React from 'react';
const Sidebar = ({ departments, selectedDept, setSelectedDept, employees }) => { const Sidebar = ({
companies,
selectedCompany,
setSelectedCompany,
departments,
selectedDept,
setSelectedDept,
employees
}) => {
// Count employees in a company
const getCompanyCount = (companyId) => {
if (!companyId) return employees.length;
return employees.filter(emp => emp.company_id === companyId).length;
};
// Count employees in a department // Count employees in a department
const getDeptCount = (deptId) => { const getDeptCount = (deptId) => {
if (!deptId) return employees.length; if (!deptId) return employees.length;
@@ -9,6 +23,36 @@ const Sidebar = ({ departments, selectedDept, setSelectedDept, employees }) => {
return ( return (
<aside className="filters-sidebar"> <aside className="filters-sidebar">
{/* Organizations section */}
<div className="filters-title">
<span>Организации</span>
</div>
<div className="filters-section" style={{ marginBottom: '1.5rem' }}>
<ul className="dept-list">
<li>
<button
className={`dept-item ${selectedCompany === null ? 'active' : ''}`}
onClick={() => setSelectedCompany(null)}
>
<span>Все организации</span>
<span className="dept-count">{getCompanyCount(null)}</span>
</button>
</li>
{companies.map((comp) => (
<li key={comp.id}>
<button
className={`dept-item ${selectedCompany === comp.id ? 'active' : ''}`}
onClick={() => setSelectedCompany(comp.id)}
>
<span>{comp.name}</span>
<span className="dept-count">{getCompanyCount(comp.id)}</span>
</button>
</li>
))}
</ul>
</div>
{/* Departments section */}
<div className="filters-title"> <div className="filters-title">
<span>Подразделения</span> <span>Подразделения</span>
</div> </div>
@@ -19,7 +63,7 @@ const Sidebar = ({ departments, selectedDept, setSelectedDept, employees }) => {
className={`dept-item ${selectedDept === null ? 'active' : ''}`} className={`dept-item ${selectedDept === null ? 'active' : ''}`}
onClick={() => setSelectedDept(null)} onClick={() => setSelectedDept(null)}
> >
<span>Все сотрудники</span> <span>Все подразделения</span>
<span className="dept-count">{getDeptCount(null)}</span> <span className="dept-count">{getDeptCount(null)}</span>
</button> </button>
</li> </li>
+2 -1
View File
@@ -446,7 +446,8 @@ button, input, select, textarea {
.table-contact-link svg { .table-contact-link svg {
width: 0.85rem; width: 0.85rem;
height: 0.85rem; height: 0.85rem;
fill: currentColor; fill: none;
stroke: currentColor;
} }
/* Location labels in table */ /* Location labels in table */
Executable
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP_NAME="corp-address-book"
CONTAINER_NAME="corporate-address-book"
INSTALL_DIR="/opt/corp-address-book"
IMAGE_NAME="corp-address-book:latest"
HOST_PORT="8180"
CONTAINER_PORT="3000"
log() {
printf '\n[%s] %s\n' "$(date +'%H:%M:%S')" "$*"
}
die() {
printf '\nERROR: %s\n' "$*" >&2
exit 1
}
# 1. Require root
if [ "${EUID:-$(id -u)}" -ne 0 ]; then
die "Run this update script as root, for example: curl -fsSL ... | sudo bash"
fi
# 2. Go to installation directory
log "Navigating to ${INSTALL_DIR}"
cd "$INSTALL_DIR" || die "Directory ${INSTALL_DIR} does not exist. Run install.sh first."
# 3. Configure git safe directory
log "Configuring git safe directory"
git config --global --add safe.directory "$INSTALL_DIR" || true
# 4. Pull changes using the deploy key
log "Pulling latest changes from Gitea"
SSH_KEY="/home/fabritsky/.ssh/gitea_corp_address_book_ed25519"
if [ ! -f "$SSH_KEY" ]; then
# Fallback: search for any deploy/private key in fabritsky's .ssh directory
SSH_KEY_FOUND=$(find /home/fabritsky/.ssh/ -type f \( -name "*gitea*" -o -name "*ed25519*" -o -name "*rsa*" \) ! -name "*.pub" | head -n 1)
if [ -n "$SSH_KEY_FOUND" ]; then
SSH_KEY="$SSH_KEY_FOUND"
fi
fi
log "Using SSH key: ${SSH_KEY}"
GIT_SSH_COMMAND="ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no -o BatchMode=yes" git pull
# 5. Build Docker Image (Re-compile frontend and backend inside container)
log "Building updated Docker image ${IMAGE_NAME}"
docker build -t "$IMAGE_NAME" "$INSTALL_DIR"
# 6. Safely replace container
log "Replacing container ${CONTAINER_NAME}"
old_name=""
if docker container inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
old_name="${CONTAINER_NAME}-old-$(date +%Y%m%d-%H%M%S)"
docker stop "$CONTAINER_NAME" >/dev/null
docker rename "$CONTAINER_NAME" "$old_name"
log "Previous container renamed to ${old_name}"
fi
log "Starting new container on port ${HOST_PORT}"
if ! docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
--env-file "${INSTALL_DIR}/.env" \
-p "${HOST_PORT}:${CONTAINER_PORT}" \
-v "${INSTALL_DIR}/data:/app/data" \
"$IMAGE_NAME" >/dev/null; then
if [ -n "$old_name" ]; then
log "New container failed to start; rolling back to ${old_name}"
docker rename "$old_name" "$CONTAINER_NAME" || true
docker start "$CONTAINER_NAME" || true
fi
die "Failed to start new container."
fi
# 7. Health Check / Status
log "Running health check..."
sleep 5
if curl -fsSI "http://127.0.0.1:${HOST_PORT}/" >/dev/null; then
log "Update completed successfully!"
log "Container Status:"
docker ps --filter "name=${CONTAINER_NAME}"
# Remove the backup old container since deployment succeeded
if [ -n "$old_name" ]; then
log "Cleaning up old backup container ${old_name}"
docker rm "$old_name" >/dev/null || true
fi
else
docker logs --tail=80 "$CONTAINER_NAME" || true
if [ -n "$old_name" ]; then
log "Health check failed; rolling back to ${old_name}"
docker stop "$CONTAINER_NAME" >/dev/null || true
docker rm "$CONTAINER_NAME" || true
docker rename "$old_name" "$CONTAINER_NAME" || true
docker start "$CONTAINER_NAME" || true
fi
die "Health check failed. Rolled back to the previous container version."
fi