feat: add housekeeping module for household staff management
* Adding flexible reminder options to birthdays * Fix database migration merge conflict * Truncate calendar popup descriptions * Log app version on backend startup * Add host-mounted data and backup folders * feat: add housekeeping module * fix: align housekeeping UI and add task creation * refactor: rebuild housekeeping experience * feat: support multiple housekeeping staff * feat: integrate housekeeping visits with calendar * feat: refine housekeeping visits and payments * feat: add housekeeping staff visit logs * feat: add housekeeping receipts and document folders * feat: localize housekeeping folders and chores * feat: refine housekeeping tabs and document folders * fix: sync housekeeping tab active state * feat: use configured app name in onboarding and manifest
This commit is contained in:
+9
-2
@@ -572,7 +572,14 @@ router.get('/me', requireAuth, (req, res) => {
|
||||
router.get('/users', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const users = db.get()
|
||||
.prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users ORDER BY display_name`)
|
||||
.prepare(`
|
||||
SELECT ${USER_PUBLIC_COLUMNS}
|
||||
FROM users
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM housekeeping_workers hw WHERE hw.user_id = users.id
|
||||
)
|
||||
ORDER BY display_name
|
||||
`)
|
||||
.all();
|
||||
res.json({ data: users.map(publicUser) });
|
||||
} catch (err) {
|
||||
@@ -952,4 +959,4 @@ router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res
|
||||
}
|
||||
});
|
||||
|
||||
export { router, sessionMiddleware, requireAuth, requireAdmin };
|
||||
export { router, sessionMiddleware, requireAuth, requireAdmin, syncFamilyMemberArtifacts, normalizeAvatarData };
|
||||
|
||||
+151
@@ -1076,6 +1076,15 @@ const MIGRATIONS = [
|
||||
},
|
||||
{
|
||||
version: 30,
|
||||
description: 'Advanced reminder options for birthdays',
|
||||
up: `
|
||||
ALTER TABLE birthdays ADD COLUMN reminder_offset TEXT;
|
||||
ALTER TABLE birthdays ADD COLUMN reminder_custom_amount INTEGER;
|
||||
ALTER TABLE birthdays ADD COLUMN reminder_custom_unit TEXT;
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 31,
|
||||
description: 'CardDAV multi-account contacts sync',
|
||||
up: `
|
||||
-- ========================================
|
||||
@@ -1209,6 +1218,148 @@ const MIGRATIONS = [
|
||||
SELECT id, assigned_to FROM calendar_events WHERE assigned_to IS NOT NULL;
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 33,
|
||||
description: 'Housekeeping work sessions, decay tasks, supply requests, and maintenance log',
|
||||
up: `
|
||||
CREATE TABLE IF NOT EXISTS housekeeping_work_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
check_in TEXT NOT NULL,
|
||||
check_out TEXT,
|
||||
daily_rate REAL NOT NULL DEFAULT 0 CHECK(daily_rate >= 0),
|
||||
extras REAL NOT NULL DEFAULT 0 CHECK(extras >= 0),
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS housekeeping_decay_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
area TEXT NOT NULL,
|
||||
frequency_days INTEGER NOT NULL CHECK(frequency_days > 0),
|
||||
last_completed TEXT,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS housekeeping_supply_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
quantity TEXT,
|
||||
shopping_item_id INTEGER REFERENCES shopping_items(id) ON DELETE SET NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS housekeeping_maintenance_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
description TEXT NOT NULL,
|
||||
photo_url TEXT,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_housekeeping_work_sessions_updated_at
|
||||
AFTER UPDATE ON housekeeping_work_sessions FOR EACH ROW
|
||||
BEGIN UPDATE housekeeping_work_sessions SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_housekeeping_decay_tasks_updated_at
|
||||
AFTER UPDATE ON housekeeping_decay_tasks FOR EACH ROW
|
||||
BEGIN UPDATE housekeeping_decay_tasks SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_housekeeping_maintenance_log_updated_at
|
||||
AFTER UPDATE ON housekeeping_maintenance_log FOR EACH ROW
|
||||
BEGIN UPDATE housekeeping_maintenance_log SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_housekeeping_sessions_check_in ON housekeeping_work_sessions(check_in);
|
||||
CREATE INDEX IF NOT EXISTS idx_housekeeping_sessions_open ON housekeeping_work_sessions(check_out);
|
||||
CREATE INDEX IF NOT EXISTS idx_housekeeping_decay_area ON housekeeping_decay_tasks(area);
|
||||
CREATE INDEX IF NOT EXISTS idx_housekeeping_decay_completed ON housekeeping_decay_tasks(last_completed);
|
||||
CREATE INDEX IF NOT EXISTS idx_housekeeping_supply_created ON housekeeping_supply_requests(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_housekeeping_maintenance_created ON housekeeping_maintenance_log(created_at);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 34,
|
||||
description: 'Housekeeping worker profile and payment tracking',
|
||||
up: `
|
||||
CREATE TABLE IF NOT EXISTS housekeeping_workers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
daily_rate REAL NOT NULL DEFAULT 0 CHECK(daily_rate >= 0),
|
||||
payment_schedule TEXT NOT NULL DEFAULT 'monthly'
|
||||
CHECK(payment_schedule IN ('daily', 'twice_monthly', 'monthly')),
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
ALTER TABLE housekeeping_work_sessions ADD COLUMN paid_at TEXT;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_housekeeping_workers_updated_at
|
||||
AFTER UPDATE ON housekeeping_workers FOR EACH ROW
|
||||
BEGIN UPDATE housekeeping_workers SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_housekeeping_workers_user ON housekeeping_workers(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_housekeeping_sessions_paid ON housekeeping_work_sessions(paid_at);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 35,
|
||||
description: 'Housekeeping per-worker sessions and calendar linkage',
|
||||
up: `
|
||||
ALTER TABLE housekeeping_workers ADD COLUMN calendar_color TEXT NOT NULL DEFAULT '#7C3AED';
|
||||
ALTER TABLE housekeeping_work_sessions ADD COLUMN worker_id INTEGER REFERENCES housekeeping_workers(id) ON DELETE SET NULL;
|
||||
ALTER TABLE housekeeping_work_sessions ADD COLUMN calendar_event_id INTEGER REFERENCES calendar_events(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_housekeeping_sessions_worker ON housekeeping_work_sessions(worker_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_housekeeping_sessions_calendar ON housekeeping_work_sessions(calendar_event_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 36,
|
||||
description: 'Housekeeping payment task linkage',
|
||||
up: `
|
||||
ALTER TABLE housekeeping_work_sessions ADD COLUMN payment_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_housekeeping_sessions_payment_task ON housekeeping_work_sessions(payment_task_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 37,
|
||||
description: 'Document folders and housekeeping receipt linkage',
|
||||
up: `
|
||||
CREATE TABLE IF NOT EXISTS family_document_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_family_document_folders_updated_at
|
||||
AFTER UPDATE ON family_document_folders FOR EACH ROW
|
||||
BEGIN UPDATE family_document_folders SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
ALTER TABLE family_documents ADD COLUMN folder_id INTEGER REFERENCES family_document_folders(id) ON DELETE SET NULL;
|
||||
ALTER TABLE housekeeping_work_sessions ADD COLUMN receipt_document_id INTEGER REFERENCES family_documents(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_family_documents_folder ON family_documents(folder_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_housekeeping_sessions_receipt ON housekeeping_work_sessions(receipt_document_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 38,
|
||||
description: 'Calendar attachment document linkage',
|
||||
up: `
|
||||
ALTER TABLE calendar_events ADD COLUMN attachment_document_id INTEGER REFERENCES family_documents(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_attachment_document ON calendar_events(attachment_document_id);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ import remindersRouter from './routes/reminders.js';
|
||||
import searchRouter from './routes/search.js';
|
||||
import familyRouter from './routes/family.js';
|
||||
import backupRouter from './routes/backup.js';
|
||||
import housekeepingRouter from './routes/housekeeping.js';
|
||||
|
||||
const log = createLogger('Server');
|
||||
const logSync = createLogger('Sync');
|
||||
@@ -175,6 +176,41 @@ app.get('/api/v1/version', (req, res) => {
|
||||
res.json({ version: APP_VERSION, app_name: appName });
|
||||
});
|
||||
|
||||
app.get('/manifest.webmanifest', (req, res) => {
|
||||
let appName = DEFAULT_APP_NAME;
|
||||
try {
|
||||
const row = db.get().prepare('SELECT value FROM sync_config WHERE key = ?').get('app_name');
|
||||
if (row?.value) appName = row.value;
|
||||
} catch {
|
||||
// fall back to default
|
||||
}
|
||||
|
||||
res.type('application/manifest+json');
|
||||
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
||||
res.json({
|
||||
name: `${appName} Familienplaner`,
|
||||
short_name: appName,
|
||||
description: 'Selbstgehosteter Familienplaner',
|
||||
id: '/',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
display_override: ['standalone', 'minimal-ui'],
|
||||
orientation: 'portrait-primary',
|
||||
theme_color: '#007AFF',
|
||||
background_color: '#F5F5F7',
|
||||
lang: 'de-DE',
|
||||
categories: ['productivity', 'lifestyle'],
|
||||
icons: [
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: '/icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
screenshots: [],
|
||||
});
|
||||
});
|
||||
|
||||
function sendOpenApi(req, res) {
|
||||
if (req.query.download === '1') {
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="openapi.json"');
|
||||
@@ -206,6 +242,7 @@ app.use('/api/v1/reminders', remindersRouter);
|
||||
app.use('/api/v1/search', searchRouter);
|
||||
app.use('/api/v1/family', familyRouter);
|
||||
app.use('/api/v1/backup', backupRouter);
|
||||
app.use('/api/v1/housekeeping', housekeepingRouter);
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Health-Check (für Docker)
|
||||
|
||||
@@ -22,6 +22,7 @@ const router = express.Router();
|
||||
|
||||
const VALID_SOURCES = ['local', 'google', 'apple', 'ics'];
|
||||
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
||||
const DEFAULT_ATTACHMENT_FOLDER = 'Calendar items';
|
||||
const ATTACHMENT_MIME = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
@@ -87,6 +88,36 @@ function parseAttachment(dataUrl) {
|
||||
return { name: null, mime, size: buffer.length, data: base64 };
|
||||
}
|
||||
|
||||
function ensureDocumentFolder(database, name, actorId) {
|
||||
const folderName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!folderName) return null;
|
||||
const existing = database.prepare('SELECT id FROM family_document_folders WHERE name = ? COLLATE NOCASE').get(folderName);
|
||||
if (existing) return existing.id;
|
||||
const result = database.prepare('INSERT INTO family_document_folders (name, created_by) VALUES (?, ?)').run(folderName, actorId);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
function createAttachmentDocument(database, attachment, body, actorId) {
|
||||
if (!attachment?.data) return null;
|
||||
const originalName = String(body.attachment_name || 'Attachment').trim() || 'Attachment';
|
||||
const folderId = ensureDocumentFolder(database, body.document_folder_name || DEFAULT_ATTACHMENT_FOLDER, actorId);
|
||||
const result = database.prepare(`
|
||||
INSERT INTO family_documents
|
||||
(name, description, category, visibility, folder_id, original_name, mime_type, file_size, content_data, created_by)
|
||||
VALUES (?, ?, 'other', 'family', ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
body.document_name || originalName.replace(/\.[^.]+$/, ''),
|
||||
body.document_description || null,
|
||||
folderId,
|
||||
originalName,
|
||||
attachment.mime,
|
||||
attachment.size,
|
||||
attachment.data,
|
||||
actorId,
|
||||
);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
function attachmentDataUrl(event) {
|
||||
if (!event?.attachment_data) return event?.attachment_data ?? null;
|
||||
if (String(event.attachment_data).startsWith('data:')) return event.attachment_data;
|
||||
@@ -661,12 +692,13 @@ router.post('/', (req, res) => {
|
||||
const attachment = req.body.attachment_data ? parseAttachment(req.body.attachment_data) : { mime: null, size: null, data: null };
|
||||
|
||||
const eventId = db.get().transaction(() => {
|
||||
const documentId = createAttachmentDocument(db.get(), attachment, req.body, userId);
|
||||
const result = db.get().prepare(`
|
||||
INSERT INTO calendar_events
|
||||
(title, description, start_datetime, end_datetime, all_day,
|
||||
location, color, icon, assigned_to, created_by, recurrence_rule,
|
||||
attachment_name, attachment_mime, attachment_size, attachment_data)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
attachment_name, attachment_mime, attachment_size, attachment_data, attachment_document_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
vTitle.value, vDesc.value,
|
||||
vStart.value, vEnd.value,
|
||||
@@ -676,7 +708,8 @@ router.post('/', (req, res) => {
|
||||
req.body.attachment_name || null,
|
||||
attachment.mime,
|
||||
attachment.size,
|
||||
attachment.data
|
||||
attachment.data,
|
||||
documentId
|
||||
);
|
||||
setEventAssignments(db.get(), result.lastInsertRowid, userIds);
|
||||
return result.lastInsertRowid;
|
||||
@@ -747,6 +780,9 @@ router.put('/:id', (req, res) => {
|
||||
const userModified = event.external_source !== 'local' ? 1 : event.user_modified;
|
||||
|
||||
db.get().transaction(() => {
|
||||
const documentId = req.body.attachment_data
|
||||
? createAttachmentDocument(db.get(), attachment, req.body, event.created_by)
|
||||
: event.attachment_document_id;
|
||||
db.get().prepare(`
|
||||
UPDATE calendar_events
|
||||
SET title = COALESCE(?, title),
|
||||
@@ -763,6 +799,7 @@ router.put('/:id', (req, res) => {
|
||||
attachment_mime = ?,
|
||||
attachment_size = ?,
|
||||
attachment_data = ?,
|
||||
attachment_document_id = ?,
|
||||
user_modified = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
@@ -780,6 +817,7 @@ router.put('/:id', (req, res) => {
|
||||
attachment.mime,
|
||||
attachment.size,
|
||||
attachment.data,
|
||||
documentId,
|
||||
userModified,
|
||||
id
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import express from 'express';
|
||||
import * as db from '../db.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
import { str, collectErrors, MAX_TEXT, MAX_TITLE } from '../middleware/validate.js';
|
||||
import { str, collectErrors, id as validateId, MAX_TEXT, MAX_TITLE } from '../middleware/validate.js';
|
||||
|
||||
const log = createLogger('Documents');
|
||||
const router = express.Router();
|
||||
@@ -70,10 +70,12 @@ function documentSelect() {
|
||||
return `
|
||||
SELECT d.id, d.name, d.description, d.category, d.status, d.visibility,
|
||||
d.original_name, d.mime_type, d.file_size, d.storage_provider,
|
||||
d.storage_key, d.created_by, d.created_at, d.updated_at,
|
||||
d.storage_key, d.folder_id, d.created_by, d.created_at, d.updated_at,
|
||||
f.name AS folder_name,
|
||||
u.display_name AS creator_name, u.avatar_color AS creator_color,
|
||||
GROUP_CONCAT(a.user_id) AS allowed_member_ids
|
||||
FROM family_documents d
|
||||
LEFT JOIN family_document_folders f ON f.id = d.folder_id
|
||||
LEFT JOIN users u ON u.id = d.created_by
|
||||
LEFT JOIN family_document_access a ON a.document_id = d.id
|
||||
`;
|
||||
@@ -90,7 +92,7 @@ function normalizeDocument(row) {
|
||||
}
|
||||
|
||||
function getVisibleDocument(id, req, includeContent = false) {
|
||||
const columns = includeContent ? 'd.*' : 'd.id, d.created_by, d.visibility, d.description';
|
||||
const columns = includeContent ? 'd.*' : 'd.id, d.created_by, d.visibility, d.description, d.folder_id';
|
||||
return db.get().prepare(`
|
||||
SELECT ${columns}
|
||||
FROM family_documents d
|
||||
@@ -105,6 +107,15 @@ function replaceAccess(documentId, memberIds) {
|
||||
for (const memberId of memberIds) insert.run(documentId, memberId);
|
||||
}
|
||||
|
||||
function ensureFolder(name, actorId) {
|
||||
const folderName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!folderName) return null;
|
||||
const existing = db.get().prepare('SELECT id FROM family_document_folders WHERE name = ? COLLATE NOCASE').get(folderName);
|
||||
if (existing) return existing.id;
|
||||
const result = db.get().prepare('INSERT INTO family_document_folders (name, created_by) VALUES (?, ?)').run(folderName, actorId);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
router.get('/meta/options', (_req, res) => {
|
||||
res.json({
|
||||
data: {
|
||||
@@ -118,16 +129,52 @@ router.get('/meta/options', (_req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/folders', (_req, res) => {
|
||||
try {
|
||||
const rows = db.get().prepare(`
|
||||
SELECT id, name, created_by, created_at, updated_at
|
||||
FROM family_document_folders
|
||||
ORDER BY name COLLATE NOCASE ASC
|
||||
`).all();
|
||||
res.json({ data: rows });
|
||||
} catch (err) {
|
||||
log.error('GET /folders error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/folders', (req, res) => {
|
||||
try {
|
||||
const vName = str(req.body.name, 'Name', { max: MAX_TITLE });
|
||||
if (vName.error) return res.status(400).json({ error: vName.error, code: 400 });
|
||||
const result = db.get().prepare('INSERT INTO family_document_folders (name, created_by) VALUES (?, ?)')
|
||||
.run(vName.value, userId(req));
|
||||
const row = db.get().prepare('SELECT id, name, created_by, created_at, updated_at FROM family_document_folders WHERE id = ?')
|
||||
.get(result.lastInsertRowid);
|
||||
res.status(201).json({ data: row });
|
||||
} catch (err) {
|
||||
if (err.message?.includes('UNIQUE constraint')) {
|
||||
return res.status(409).json({ error: 'Folder already exists.', code: 409 });
|
||||
}
|
||||
log.error('POST /folders error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const status = STATUSES.includes(req.query.status) ? req.query.status : 'active';
|
||||
const category = CATEGORIES.includes(req.query.category) ? req.query.category : null;
|
||||
const params = { userId: userId(req), status, category };
|
||||
const folderId = req.query.folder_id !== undefined && req.query.folder_id !== ''
|
||||
? Number(req.query.folder_id)
|
||||
: null;
|
||||
const params = { userId: userId(req), status, category, folderId };
|
||||
const rows = db.get().prepare(`
|
||||
${documentSelect()}
|
||||
WHERE ${canSeeSql('d')}
|
||||
AND d.status = @status
|
||||
AND (@category IS NULL OR d.category = @category)
|
||||
AND (@folderId IS NULL OR d.folder_id = @folderId)
|
||||
GROUP BY d.id
|
||||
ORDER BY d.updated_at DESC
|
||||
`).all(params);
|
||||
@@ -143,21 +190,27 @@ router.post('/', (req, res) => {
|
||||
const vName = str(req.body.name, 'Name', { max: MAX_TITLE });
|
||||
const vDescription = str(req.body.description, 'Description', { max: MAX_TEXT, required: false });
|
||||
const vOriginalName = str(req.body.original_name, 'Original filename', { max: MAX_TITLE });
|
||||
const errors = collectErrors([vName, vDescription, vOriginalName]);
|
||||
const vFolderName = str(req.body.folder_name, 'Folder name', { max: MAX_TITLE, required: false });
|
||||
const errors = collectErrors([vName, vDescription, vOriginalName, vFolderName]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
|
||||
const category = CATEGORIES.includes(req.body.category) ? req.body.category : 'other';
|
||||
const visibility = VISIBILITIES.includes(req.body.visibility) ? req.body.visibility : 'family';
|
||||
const vFolderId = req.body.folder_id !== undefined && req.body.folder_id !== null && req.body.folder_id !== ''
|
||||
? validateId(req.body.folder_id, 'folder_id')
|
||||
: { value: null, error: null };
|
||||
if (vFolderId.error) return res.status(400).json({ error: vFolderId.error, code: 400 });
|
||||
const parsed = parseDataUrl(req.body.content_data);
|
||||
if (parsed.error) return res.status(400).json({ error: parsed.error, code: 400 });
|
||||
|
||||
const allowedIds = visibility === 'restricted' ? parseMemberIds(req.body.allowed_member_ids) : [];
|
||||
const folderId = vFolderId.value ?? ensureFolder(vFolderName.value, userId(req));
|
||||
const database = db.get();
|
||||
const result = database.prepare(`
|
||||
INSERT INTO family_documents
|
||||
(name, description, category, visibility, original_name, mime_type, file_size, content_data, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(vName.value, vDescription.value, category, visibility, vOriginalName.value, parsed.mime, parsed.size, parsed.base64, userId(req));
|
||||
(name, description, category, visibility, folder_id, original_name, mime_type, file_size, content_data, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(vName.value, vDescription.value, category, visibility, folderId, vOriginalName.value, parsed.mime, parsed.size, parsed.base64, userId(req));
|
||||
if (visibility === 'restricted') replaceAccess(result.lastInsertRowid, allowedIds);
|
||||
|
||||
const row = database.prepare(`
|
||||
@@ -187,13 +240,18 @@ router.put('/:id', (req, res) => {
|
||||
const category = req.body.category !== undefined && CATEGORIES.includes(req.body.category) ? req.body.category : null;
|
||||
const visibility = req.body.visibility !== undefined && VISIBILITIES.includes(req.body.visibility) ? req.body.visibility : null;
|
||||
const status = req.body.status !== undefined && STATUSES.includes(req.body.status) ? req.body.status : null;
|
||||
const vFolderId = req.body.folder_id !== undefined && req.body.folder_id !== null && req.body.folder_id !== ''
|
||||
? validateId(req.body.folder_id, 'folder_id')
|
||||
: { value: null, error: null };
|
||||
if (vFolderId.error) return res.status(400).json({ error: vFolderId.error, code: 400 });
|
||||
db.get().prepare(`
|
||||
UPDATE family_documents
|
||||
SET name = COALESCE(?, name),
|
||||
description = ?,
|
||||
category = COALESCE(?, category),
|
||||
visibility = COALESCE(?, visibility),
|
||||
status = COALESCE(?, status)
|
||||
status = COALESCE(?, status),
|
||||
folder_id = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
req.body.name !== undefined ? vName.value : null,
|
||||
@@ -201,6 +259,7 @@ router.put('/:id', (req, res) => {
|
||||
category,
|
||||
visibility,
|
||||
status,
|
||||
req.body.folder_id !== undefined ? vFolderId.value : existing.folder_id,
|
||||
id
|
||||
);
|
||||
if ((visibility || existing.visibility) === 'restricted') replaceAccess(id, parseMemberIds(req.body.allowed_member_ids));
|
||||
|
||||
@@ -26,6 +26,9 @@ router.get('/members', (req, res) => {
|
||||
FROM users u
|
||||
LEFT JOIN contacts c ON c.family_user_id = u.id
|
||||
LEFT JOIN birthdays b ON b.family_user_id = u.id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM housekeeping_workers hw WHERE hw.user_id = u.id
|
||||
)
|
||||
ORDER BY u.display_name COLLATE NOCASE ASC
|
||||
`).all();
|
||||
res.json({ data: members });
|
||||
|
||||
@@ -0,0 +1,956 @@
|
||||
/**
|
||||
* Modul: Housekeeping
|
||||
* Zweck: REST-API fuer Ponto/Financeiro, tarefas dinamicas, insumos e ocorrencias
|
||||
* Abhängigkeiten: express, server/db.js
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'node:crypto';
|
||||
import { createLogger } from '../logger.js';
|
||||
import * as db from '../db.js';
|
||||
import { normalizeAvatarData, syncFamilyMemberArtifacts } from '../auth.js';
|
||||
import { collectErrors, color, date, datetime, month, num, oneOf, str, id as validateId, MAX_SHORT, MAX_TEXT, MAX_TITLE } from '../middleware/validate.js';
|
||||
|
||||
const log = createLogger('Housekeeping');
|
||||
const router = express.Router();
|
||||
|
||||
const MAX_PHOTO_DATA_LENGTH = 6 * 1024 * 1024;
|
||||
const IMAGE_DATA_RE = /^data:image\/(?:png|jpeg|webp);base64,[a-z0-9+/=]+$/i;
|
||||
const PAYMENT_SCHEDULES = ['daily', 'twice_monthly', 'monthly'];
|
||||
const DEFAULT_CALENDAR_COLOR = '#7C3AED';
|
||||
const HOUSEKEEPING_EVENT_ICON = 'paintbrush';
|
||||
const PAYMENT_TASKS_PREF = 'housekeeping_payment_tasks';
|
||||
|
||||
const TASK_TEMPLATES = [
|
||||
{ key: 'cleanBathrooms', name: 'Clean bathrooms', area: 'Bathrooms', frequency_days: 7 },
|
||||
{ key: 'mopKitchenFloor', name: 'Mop kitchen floor', area: 'Kitchen', frequency_days: 7 },
|
||||
{ key: 'dustLivingRoom', name: 'Dust living room', area: 'Living room', frequency_days: 14 },
|
||||
{ key: 'changeBedLinens', name: 'Change bed linens', area: 'Bedrooms', frequency_days: 14 },
|
||||
{ key: 'cleanRefrigerator', name: 'Clean refrigerator', area: 'Kitchen', frequency_days: 30 },
|
||||
{ key: 'cleanWindows', name: 'Clean windows', area: 'Whole house', frequency_days: 30 },
|
||||
{ key: 'deepCleanOven', name: 'Deep clean oven', area: 'Kitchen', frequency_days: 60 },
|
||||
{ key: 'washOutdoor', name: 'Wash balcony/patio', area: 'Outdoor', frequency_days: 30 },
|
||||
];
|
||||
|
||||
function userId(req) {
|
||||
return req.authUserId || req.session.userId;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function currentMonth() {
|
||||
return nowIso().slice(0, 7);
|
||||
}
|
||||
|
||||
function publicSession(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
worker_id: row.worker_id ?? null,
|
||||
calendar_event_id: row.calendar_event_id ?? null,
|
||||
payment_task_id: row.payment_task_id ?? null,
|
||||
receipt_document_id: row.receipt_document_id ?? null,
|
||||
check_in: row.check_in,
|
||||
check_out: row.check_out,
|
||||
daily_rate: Number(row.daily_rate || 0),
|
||||
extras: Number(row.extras || 0),
|
||||
paid_at: row.paid_at ?? null,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function publicWorker(row) {
|
||||
if (!row) return null;
|
||||
const todaySession = loadTodaySession(row.id);
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
username: row.username,
|
||||
display_name: row.display_name,
|
||||
avatar_color: row.avatar_color,
|
||||
avatar_data: row.avatar_data ?? null,
|
||||
phone: row.phone ?? null,
|
||||
email: row.email ?? null,
|
||||
birth_date: row.birth_date ?? null,
|
||||
daily_rate: Number(row.daily_rate || 0),
|
||||
payment_schedule: row.payment_schedule,
|
||||
calendar_color: row.calendar_color || DEFAULT_CALENDAR_COLOR,
|
||||
current_session: publicSession(todaySession),
|
||||
today_session: publicSession(todaySession),
|
||||
notes: row.notes ?? null,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function taskUrgency(row, now = new Date()) {
|
||||
const frequencyDays = Math.max(1, Number(row.frequency_days || 1));
|
||||
const completed = row.last_completed ? new Date(row.last_completed) : null;
|
||||
if (!completed || Number.isNaN(completed.getTime())) {
|
||||
return { urgency: Number.MAX_SAFE_INTEGER, status: 'overdue', due_date: null };
|
||||
}
|
||||
|
||||
const due = new Date(completed);
|
||||
due.setDate(due.getDate() + frequencyDays);
|
||||
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate());
|
||||
const elapsedDays = Math.max(0, (now.getTime() - completed.getTime()) / 86_400_000);
|
||||
const urgency = elapsedDays / frequencyDays;
|
||||
|
||||
let status = 'ok';
|
||||
if (today.getTime() > dueDay.getTime()) status = 'overdue';
|
||||
else if (today.getTime() === dueDay.getTime()) status = 'today';
|
||||
|
||||
return { urgency, status, due_date: due.toISOString() };
|
||||
}
|
||||
|
||||
function publicDecayTask(row) {
|
||||
const computed = taskUrgency(row);
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
area: row.area,
|
||||
frequency_days: row.frequency_days,
|
||||
last_completed: row.last_completed,
|
||||
urgency: computed.urgency === Number.MAX_SAFE_INTEGER ? null : Number(computed.urgency.toFixed(3)),
|
||||
urgency_status: computed.status,
|
||||
due_date: computed.due_date,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function validatePhotoUrl(value) {
|
||||
if (value === undefined || value === null || value === '') return { value: null, error: null };
|
||||
if (typeof value !== 'string') return { value: null, error: 'Photo must be a data URL string.' };
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > MAX_PHOTO_DATA_LENGTH) return { value: null, error: 'Photo is too large.' };
|
||||
if (!IMAGE_DATA_RE.test(trimmed)) return { value: null, error: 'Photo must be PNG, JPEG, or WebP.' };
|
||||
return { value: trimmed, error: null };
|
||||
}
|
||||
|
||||
function loadOpenSession(workerId = null) {
|
||||
if (workerId) {
|
||||
return db.get().prepare(`
|
||||
SELECT * FROM housekeeping_work_sessions
|
||||
WHERE check_out IS NULL AND worker_id = ?
|
||||
ORDER BY check_in DESC
|
||||
LIMIT 1
|
||||
`).get(workerId);
|
||||
}
|
||||
return db.get().prepare(`
|
||||
SELECT * FROM housekeeping_work_sessions
|
||||
WHERE check_out IS NULL
|
||||
ORDER BY check_in DESC
|
||||
LIMIT 1
|
||||
`).get();
|
||||
}
|
||||
|
||||
function loadTodaySession(workerId) {
|
||||
return db.get().prepare(`
|
||||
SELECT * FROM housekeeping_work_sessions
|
||||
WHERE worker_id = ? AND substr(check_in, 1, 10) = substr(strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 1, 10)
|
||||
ORDER BY check_in DESC
|
||||
LIMIT 1
|
||||
`).get(workerId);
|
||||
}
|
||||
|
||||
function housekeepingPaymentTasksEnabled(database = db.get()) {
|
||||
const row = database.prepare('SELECT value FROM sync_config WHERE key = ?').get(PAYMENT_TASKS_PREF);
|
||||
return row?.value === '1';
|
||||
}
|
||||
|
||||
function defaultDailyRate() {
|
||||
const worker = loadWorker();
|
||||
if (worker) return Number(worker.daily_rate || 0);
|
||||
const row = db.get().prepare(`
|
||||
SELECT daily_rate FROM housekeeping_work_sessions
|
||||
ORDER BY check_in DESC
|
||||
LIMIT 1
|
||||
`).get();
|
||||
return Number(row?.daily_rate || 0);
|
||||
}
|
||||
|
||||
function loadWorker() {
|
||||
return loadWorkers()[0] ?? null;
|
||||
}
|
||||
|
||||
function loadWorkers() {
|
||||
return db.get().prepare(`
|
||||
SELECT hw.*,
|
||||
u.username,
|
||||
u.display_name,
|
||||
u.avatar_color,
|
||||
u.avatar_data,
|
||||
c.phone,
|
||||
c.email,
|
||||
b.birth_date
|
||||
FROM housekeeping_workers hw
|
||||
JOIN users u ON u.id = hw.user_id
|
||||
LEFT JOIN contacts c ON c.family_user_id = u.id
|
||||
LEFT JOIN birthdays b ON b.family_user_id = u.id
|
||||
ORDER BY u.display_name COLLATE NOCASE ASC
|
||||
`).all();
|
||||
}
|
||||
|
||||
function createVisitCalendarEvent(database, worker, checkIn, actorId, title = null) {
|
||||
const visitDate = checkIn.slice(0, 10);
|
||||
const result = database.prepare(`
|
||||
INSERT INTO calendar_events
|
||||
(title, start_datetime, end_datetime, all_day, color, icon, assigned_to, created_by, external_source)
|
||||
VALUES (?, ?, NULL, 1, ?, ?, ?, ?, 'local')
|
||||
`).run(
|
||||
title || `Housekeeping: ${worker.display_name}`,
|
||||
visitDate,
|
||||
worker.calendar_color || DEFAULT_CALENDAR_COLOR,
|
||||
HOUSEKEEPING_EVENT_ICON,
|
||||
worker.user_id,
|
||||
actorId,
|
||||
);
|
||||
database.prepare('INSERT OR IGNORE INTO event_assignments (event_id, user_id) VALUES (?, ?)')
|
||||
.run(result.lastInsertRowid, worker.user_id);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
function createPaymentTask(database, worker, checkIn, amount, actorId, title = null, description = null) {
|
||||
const visitDate = checkIn.slice(0, 10);
|
||||
const result = database.prepare(`
|
||||
INSERT INTO tasks (title, description, due_date, priority, category, status, created_by)
|
||||
VALUES (?, ?, ?, 'medium', 'household', 'open', ?)
|
||||
`).run(
|
||||
title || `Pay ${worker.display_name} for housekeeping`,
|
||||
description || `Housekeeping visit on ${visitDate}. Amount due: ${amount.toFixed(2)}.`,
|
||||
visitDate,
|
||||
actorId,
|
||||
);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
function updateVisitLinks(database, session, worker, checkIn, dailyRate, extras, eventTitle = null, paymentTitle = null, paymentDescription = null) {
|
||||
const visitDate = checkIn.slice(0, 10);
|
||||
if (session.calendar_event_id) {
|
||||
database.prepare(`
|
||||
UPDATE calendar_events
|
||||
SET title = COALESCE(?, title),
|
||||
start_datetime = ?,
|
||||
end_datetime = NULL,
|
||||
all_day = 1,
|
||||
color = ?,
|
||||
icon = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
eventTitle,
|
||||
visitDate,
|
||||
worker?.calendar_color || DEFAULT_CALENDAR_COLOR,
|
||||
HOUSEKEEPING_EVENT_ICON,
|
||||
session.calendar_event_id,
|
||||
);
|
||||
}
|
||||
if (session.payment_task_id) {
|
||||
const totalAmount = Number(dailyRate || 0) + Number(extras || 0);
|
||||
database.prepare(`
|
||||
UPDATE tasks
|
||||
SET title = COALESCE(?, title),
|
||||
description = COALESCE(?, description),
|
||||
due_date = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
paymentTitle,
|
||||
paymentDescription || `Housekeeping visit on ${visitDate}. Amount due: ${totalAmount.toFixed(2)}.`,
|
||||
visitDate,
|
||||
session.payment_task_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteVisitLinks(database, session) {
|
||||
if (session.calendar_event_id) database.prepare('DELETE FROM calendar_events WHERE id = ?').run(session.calendar_event_id);
|
||||
if (session.payment_task_id) database.prepare('DELETE FROM tasks WHERE id = ?').run(session.payment_task_id);
|
||||
}
|
||||
|
||||
function reconcilePaymentTasks(database = db.get()) {
|
||||
database.prepare(`
|
||||
UPDATE housekeeping_work_sessions
|
||||
SET paid_at = COALESCE(paid_at, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
WHERE payment_task_id IS NOT NULL
|
||||
AND paid_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tasks
|
||||
WHERE tasks.id = housekeeping_work_sessions.payment_task_id
|
||||
AND tasks.status = 'done'
|
||||
)
|
||||
`).run();
|
||||
}
|
||||
|
||||
function loadWorkerById(workerId) {
|
||||
return db.get().prepare(`
|
||||
SELECT hw.*,
|
||||
u.username,
|
||||
u.display_name,
|
||||
u.avatar_color,
|
||||
u.avatar_data,
|
||||
c.phone,
|
||||
c.email,
|
||||
b.birth_date
|
||||
FROM housekeeping_workers hw
|
||||
JOIN users u ON u.id = hw.user_id
|
||||
LEFT JOIN contacts c ON c.family_user_id = u.id
|
||||
LEFT JOIN birthdays b ON b.family_user_id = u.id
|
||||
WHERE hw.id = ?
|
||||
`).get(workerId);
|
||||
}
|
||||
|
||||
function monthlySummary(monthValue = currentMonth()) {
|
||||
const row = db.get().prepare(`
|
||||
SELECT
|
||||
COUNT(*) AS session_count,
|
||||
COALESCE(SUM(daily_rate), 0) AS daily_total,
|
||||
COALESCE(SUM(extras), 0) AS extras_total,
|
||||
COALESCE(SUM(daily_rate + extras), 0) AS total_amount
|
||||
FROM housekeeping_work_sessions
|
||||
WHERE substr(check_in, 1, 7) = ?
|
||||
`).get(monthValue);
|
||||
|
||||
return {
|
||||
month: monthValue,
|
||||
session_count: row.session_count,
|
||||
daily_total: Number(row.daily_total || 0),
|
||||
extras_total: Number(row.extras_total || 0),
|
||||
total_amount: Number(row.total_amount || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function housekeepingDashboard() {
|
||||
reconcilePaymentTasks();
|
||||
const monthValue = currentMonth();
|
||||
const workers = loadWorkers().map(publicWorker);
|
||||
const worker = workers[0] ?? null;
|
||||
const summary = monthlySummary(monthValue);
|
||||
const lastVisit = db.get().prepare(`
|
||||
SELECT * FROM housekeeping_work_sessions
|
||||
ORDER BY check_in DESC
|
||||
LIMIT 1
|
||||
`).get();
|
||||
const payment = db.get().prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN paid_at IS NULL THEN daily_rate + extras ELSE 0 END), 0) AS pending,
|
||||
COALESCE(SUM(CASE WHEN paid_at IS NOT NULL THEN daily_rate + extras ELSE 0 END), 0) AS paid
|
||||
FROM housekeeping_work_sessions
|
||||
WHERE substr(check_in, 1, 7) = ?
|
||||
`).get(monthValue);
|
||||
const taskRows = db.get().prepare('SELECT * FROM housekeeping_decay_tasks').all();
|
||||
const tasks = taskRows.map(publicDecayTask);
|
||||
const chart = db.get().prepare(`
|
||||
SELECT substr(check_in, 1, 7) AS month,
|
||||
COALESCE(SUM(daily_rate + extras), 0) AS total,
|
||||
COALESCE(SUM(CASE WHEN paid_at IS NULL THEN daily_rate + extras ELSE 0 END), 0) AS pending
|
||||
FROM housekeeping_work_sessions
|
||||
WHERE check_in >= strftime('%Y-%m-01T00:00:00Z', 'now', '-5 months')
|
||||
GROUP BY substr(check_in, 1, 7)
|
||||
ORDER BY month ASC
|
||||
`).all().map((row) => ({
|
||||
month: row.month,
|
||||
total: Number(row.total || 0),
|
||||
pending: Number(row.pending || 0),
|
||||
}));
|
||||
|
||||
return {
|
||||
worker,
|
||||
workers,
|
||||
current_session: null,
|
||||
visits_this_month: summary.session_count,
|
||||
last_visit: publicSession(lastVisit),
|
||||
pending_tasks: tasks.filter((task) => task.urgency_status !== 'ok').length,
|
||||
finished_tasks_this_month: taskRows.filter((task) => task.last_completed?.slice(0, 7) === monthValue).length,
|
||||
pending_payments: Number(payment.pending || 0),
|
||||
paid_this_month: Number(payment.paid || 0),
|
||||
monthly_payments: chart,
|
||||
};
|
||||
}
|
||||
|
||||
function assertAdmin(req, res) {
|
||||
if (req.authRole === 'admin') return true;
|
||||
res.status(403).json({ error: 'Permission denied.', code: 403 });
|
||||
return false;
|
||||
}
|
||||
|
||||
async function createWorkerUser({ username, displayName, avatarColor, avatarData, actorUserId }) {
|
||||
const finalUsername = username || `housekeeper_${Date.now()}`;
|
||||
const password = crypto.randomBytes(24).toString('base64url');
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
const result = db.get().prepare(`
|
||||
INSERT INTO users (username, display_name, password_hash, avatar_color, avatar_data, role, family_role)
|
||||
VALUES (?, ?, ?, ?, ?, 'member', 'other')
|
||||
`).run(finalUsername, displayName, hash, avatarColor || '#7C3AED', avatarData ?? null);
|
||||
syncFamilyMemberArtifacts(db.get(), result.lastInsertRowid, {
|
||||
displayName,
|
||||
avatarData: avatarData ?? null,
|
||||
actorUserId,
|
||||
});
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
function defaultShoppingCategory() {
|
||||
const preferred = db.get()
|
||||
.prepare("SELECT name FROM shopping_categories WHERE name = 'Haushalt' COLLATE NOCASE LIMIT 1")
|
||||
.get();
|
||||
if (preferred) return preferred.name;
|
||||
const fallback = db.get()
|
||||
.prepare("SELECT name FROM shopping_categories WHERE name = 'Sonstiges' COLLATE NOCASE LIMIT 1")
|
||||
.get();
|
||||
return fallback?.name || 'Sonstiges';
|
||||
}
|
||||
|
||||
function defaultShoppingList(actorId) {
|
||||
const existing = db.get().prepare(`
|
||||
SELECT id FROM shopping_lists
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1
|
||||
`).get();
|
||||
if (existing) return existing.id;
|
||||
|
||||
const result = db.get()
|
||||
.prepare('INSERT INTO shopping_lists (name, created_by) VALUES (?, ?)')
|
||||
.run('Housekeeping', actorId);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
router.get('/dashboard', (_req, res) => {
|
||||
try {
|
||||
res.json({ data: housekeepingDashboard() });
|
||||
} catch (err) {
|
||||
log.error('GET /dashboard error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/task-templates', (_req, res) => {
|
||||
res.json({ data: TASK_TEMPLATES });
|
||||
});
|
||||
|
||||
router.get('/worker', (_req, res) => {
|
||||
try {
|
||||
res.json({ data: publicWorker(loadWorker()) });
|
||||
} catch (err) {
|
||||
log.error('GET /worker error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/workers', (_req, res) => {
|
||||
try {
|
||||
res.json({ data: loadWorkers().map(publicWorker) });
|
||||
} catch (err) {
|
||||
log.error('GET /workers error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/worker', async (req, res) => {
|
||||
if (!assertAdmin(req, res)) return;
|
||||
try {
|
||||
const vWorkerId = req.body.id !== undefined && req.body.id !== null && req.body.id !== ''
|
||||
? validateId(req.body.id, 'id')
|
||||
: { value: null, error: null };
|
||||
if (vWorkerId.error) return res.status(400).json({ error: vWorkerId.error, code: 400 });
|
||||
const existing = vWorkerId.value ? loadWorkerById(vWorkerId.value) : null;
|
||||
if (vWorkerId.value && !existing) return res.status(404).json({ error: 'Housekeeper not found.', code: 404 });
|
||||
|
||||
const vDisplayName = str(req.body.display_name, 'display_name', { max: 128 });
|
||||
const vUsername = str(req.body.username, 'username', { max: 64, required: false });
|
||||
const vPhone = str(req.body.phone, 'phone', { max: MAX_SHORT, required: false });
|
||||
const vEmail = str(req.body.email, 'email', { max: MAX_TITLE, required: false });
|
||||
const vBirthDate = date(req.body.birth_date, 'birth_date');
|
||||
const vDailyRate = num(req.body.daily_rate, 'daily_rate', { required: true });
|
||||
const vSchedule = oneOf(req.body.payment_schedule || 'monthly', PAYMENT_SCHEDULES, 'payment_schedule');
|
||||
const vCalendarColor = color(req.body.calendar_color || DEFAULT_CALENDAR_COLOR, 'calendar_color');
|
||||
const vNotes = str(req.body.notes, 'notes', { max: MAX_TEXT, required: false });
|
||||
const errors = collectErrors([vDisplayName, vUsername, vPhone, vEmail, vBirthDate, vDailyRate, vSchedule, vCalendarColor, vNotes]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
if (vUsername.value && !/^[a-zA-Z0-9._-]{3,64}$/.test(vUsername.value)) {
|
||||
return res.status(400).json({ error: 'Username must be 3-64 characters long and may only contain letters, numbers, dots, hyphens, and underscores.', code: 400 });
|
||||
}
|
||||
if (vDailyRate.value < 0) {
|
||||
return res.status(400).json({ error: 'daily_rate must be greater than or equal to zero.', code: 400 });
|
||||
}
|
||||
const avatarColor = String(req.body.avatar_color || '#7C3AED').trim();
|
||||
const avatarData = req.body.avatar_data !== undefined
|
||||
? normalizeAvatarData(req.body.avatar_data)
|
||||
: existing?.avatar_data ?? null;
|
||||
if (avatarData?.error) {
|
||||
return res.status(400).json({ error: avatarData.error, code: 400 });
|
||||
}
|
||||
|
||||
const actorId = userId(req);
|
||||
const targetUserId = existing ? existing.user_id : await createWorkerUser({
|
||||
username: vUsername.value,
|
||||
displayName: vDisplayName.value,
|
||||
avatarColor,
|
||||
avatarData,
|
||||
actorUserId: actorId,
|
||||
});
|
||||
|
||||
db.get().transaction(() => {
|
||||
db.get().prepare(`
|
||||
UPDATE users
|
||||
SET username = ?, display_name = ?, avatar_color = ?, avatar_data = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
vUsername.value || existing?.username || `housekeeper_${targetUserId}`,
|
||||
vDisplayName.value,
|
||||
avatarColor || '#7C3AED',
|
||||
avatarData ?? null,
|
||||
targetUserId,
|
||||
);
|
||||
db.get().prepare(`
|
||||
INSERT INTO housekeeping_workers (user_id, daily_rate, payment_schedule, calendar_color, notes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
daily_rate = excluded.daily_rate,
|
||||
payment_schedule = excluded.payment_schedule,
|
||||
calendar_color = excluded.calendar_color,
|
||||
notes = excluded.notes
|
||||
`).run(targetUserId, vDailyRate.value, vSchedule.value, vCalendarColor.value, vNotes.value);
|
||||
syncFamilyMemberArtifacts(db.get(), targetUserId, {
|
||||
displayName: vDisplayName.value,
|
||||
phone: vPhone.value,
|
||||
email: vEmail.value,
|
||||
birthDate: vBirthDate.value,
|
||||
avatarData: avatarData ?? null,
|
||||
actorUserId: actorId,
|
||||
});
|
||||
})();
|
||||
|
||||
const saved = existing ? loadWorkerById(existing.id) : loadWorkers().find((worker) => worker.user_id === targetUserId);
|
||||
res.status(existing ? 200 : 201).json({ data: publicWorker(saved) });
|
||||
} catch (err) {
|
||||
if (err.message?.includes('UNIQUE constraint')) {
|
||||
return res.status(409).json({ error: 'Username is already taken.', code: 409 });
|
||||
}
|
||||
log.error('POST /worker error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/summary', (req, res) => {
|
||||
try {
|
||||
const vMonth = month(req.query.month, 'month');
|
||||
if (vMonth.error) return res.status(400).json({ error: vMonth.error, code: 400 });
|
||||
res.json({
|
||||
data: {
|
||||
current_session: publicSession(loadOpenSession()),
|
||||
default_daily_rate: defaultDailyRate(),
|
||||
summary: monthlySummary(vMonth.value || currentMonth()),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('GET /summary error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/work-sessions', (req, res) => {
|
||||
try {
|
||||
reconcilePaymentTasks();
|
||||
const vMonth = month(req.query.month, 'month');
|
||||
if (vMonth.error) return res.status(400).json({ error: vMonth.error, code: 400 });
|
||||
const rows = db.get().prepare(`
|
||||
SELECT * FROM housekeeping_work_sessions
|
||||
WHERE substr(check_in, 1, 7) = ?
|
||||
ORDER BY check_in DESC
|
||||
`).all(vMonth.value || currentMonth());
|
||||
res.json({ data: rows.map(publicSession) });
|
||||
} catch (err) {
|
||||
log.error('GET /work-sessions error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/visits', (req, res) => {
|
||||
try {
|
||||
reconcilePaymentTasks();
|
||||
const vMonth = month(req.query.month, 'month');
|
||||
if (vMonth.error) return res.status(400).json({ error: vMonth.error, code: 400 });
|
||||
const vWorkerId = req.query.worker_id !== undefined && req.query.worker_id !== ''
|
||||
? validateId(req.query.worker_id, 'worker_id')
|
||||
: { value: null, error: null };
|
||||
if (vWorkerId.error) return res.status(400).json({ error: vWorkerId.error, code: 400 });
|
||||
const selectedMonth = vMonth.value || currentMonth();
|
||||
const rows = db.get().prepare(`
|
||||
SELECT hws.*,
|
||||
hw.payment_schedule,
|
||||
u.display_name AS worker_name,
|
||||
u.avatar_color AS worker_avatar_color,
|
||||
u.avatar_data AS worker_avatar_data,
|
||||
t.status AS payment_task_status,
|
||||
t.title AS payment_task_title,
|
||||
fd.name AS receipt_document_name
|
||||
FROM housekeeping_work_sessions hws
|
||||
LEFT JOIN housekeeping_workers hw ON hw.id = hws.worker_id
|
||||
LEFT JOIN users u ON u.id = hw.user_id
|
||||
LEFT JOIN tasks t ON t.id = hws.payment_task_id
|
||||
LEFT JOIN family_documents fd ON fd.id = hws.receipt_document_id
|
||||
WHERE substr(hws.check_in, 1, 7) = ?
|
||||
AND (? IS NULL OR hws.worker_id = ?)
|
||||
ORDER BY hws.check_in DESC
|
||||
`).all(selectedMonth, vWorkerId.value, vWorkerId.value);
|
||||
const visits = rows.map((row) => ({
|
||||
...publicSession(row),
|
||||
worker_name: row.worker_name ?? null,
|
||||
worker_avatar_color: row.worker_avatar_color ?? DEFAULT_CALENDAR_COLOR,
|
||||
worker_avatar_data: row.worker_avatar_data ?? null,
|
||||
payment_schedule: row.payment_schedule ?? 'monthly',
|
||||
payment_task_status: row.payment_task_status ?? null,
|
||||
payment_task_title: row.payment_task_title ?? null,
|
||||
receipt_document_name: row.receipt_document_name ?? null,
|
||||
total_amount: Number(row.daily_rate || 0) + Number(row.extras || 0),
|
||||
}));
|
||||
const totals = visits.reduce((acc, visit) => {
|
||||
acc.total += visit.total_amount;
|
||||
if (visit.paid_at) acc.paid += visit.total_amount;
|
||||
else acc.pending += visit.total_amount;
|
||||
return acc;
|
||||
}, { total: 0, paid: 0, pending: 0 });
|
||||
res.json({ data: { month: selectedMonth, visits, totals } });
|
||||
} catch (err) {
|
||||
log.error('GET /visits error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/work-sessions/check-in', (req, res) => {
|
||||
try {
|
||||
if (loadWorkers().length === 0) {
|
||||
return res.status(400).json({ error: 'Add a housekeeper before checking in.', code: 400 });
|
||||
}
|
||||
const vWorkerId = validateId(req.body.worker_id, 'worker_id');
|
||||
if (vWorkerId.error) return res.status(400).json({ error: vWorkerId.error, code: 400 });
|
||||
const worker = loadWorkerById(vWorkerId.value);
|
||||
if (!worker) return res.status(404).json({ error: 'Housekeeper not found.', code: 404 });
|
||||
if (loadTodaySession(worker.id)) return res.status(409).json({ error: 'A visit is already recorded today for this housekeeper.', code: 409 });
|
||||
|
||||
const vDailyRate = num(req.body.daily_rate, 'daily_rate', { required: true });
|
||||
const vExtras = num(req.body.extras, 'extras');
|
||||
const vEventTitle = str(req.body.event_title, 'event_title', { max: MAX_TITLE, required: false });
|
||||
const vPaymentTitle = str(req.body.payment_title, 'payment_title', { max: MAX_TITLE, required: false });
|
||||
const vPaymentDescription = str(req.body.payment_description, 'payment_description', { max: MAX_TEXT, required: false });
|
||||
const errors = collectErrors([vDailyRate, vExtras, vEventTitle, vPaymentTitle, vPaymentDescription]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
if (vDailyRate.value < 0 || (vExtras.value ?? 0) < 0) {
|
||||
return res.status(400).json({ error: 'Amounts must be greater than or equal to zero.', code: 400 });
|
||||
}
|
||||
|
||||
const actorId = userId(req);
|
||||
const checkIn = nowIso();
|
||||
const result = db.get().transaction(() => {
|
||||
const eventId = createVisitCalendarEvent(db.get(), worker, checkIn, actorId, vEventTitle.value);
|
||||
const totalAmount = Number(vDailyRate.value || 0) + Number(vExtras.value || 0);
|
||||
const taskId = housekeepingPaymentTasksEnabled(db.get())
|
||||
? createPaymentTask(db.get(), worker, checkIn, totalAmount, actorId, vPaymentTitle.value, vPaymentDescription.value)
|
||||
: null;
|
||||
return db.get().prepare(`
|
||||
INSERT INTO housekeeping_work_sessions (worker_id, check_in, check_out, daily_rate, extras, calendar_event_id, payment_task_id, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(worker.id, checkIn, checkIn, vDailyRate.value, vExtras.value ?? 0, eventId, taskId, actorId);
|
||||
})();
|
||||
const row = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json({ data: publicSession(row), summary: monthlySummary() });
|
||||
} catch (err) {
|
||||
log.error('POST /work-sessions/check-in error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/visits/:id', (req, res) => {
|
||||
try {
|
||||
const vId = validateId(req.params.id, 'id');
|
||||
if (vId.error) return res.status(400).json({ error: vId.error, code: 400 });
|
||||
const existing = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(vId.value);
|
||||
if (!existing) return res.status(404).json({ error: 'Visit not found.', code: 404 });
|
||||
|
||||
const vDate = date(req.body.date, 'date', true);
|
||||
const vDailyRate = num(req.body.daily_rate, 'daily_rate', { required: true });
|
||||
const vExtras = num(req.body.extras, 'extras');
|
||||
const vEventTitle = str(req.body.event_title, 'event_title', { max: MAX_TITLE, required: false });
|
||||
const vPaymentTitle = str(req.body.payment_title, 'payment_title', { max: MAX_TITLE, required: false });
|
||||
const vPaymentDescription = str(req.body.payment_description, 'payment_description', { max: MAX_TEXT, required: false });
|
||||
const vReceiptId = req.body.receipt_document_id !== undefined && req.body.receipt_document_id !== null && req.body.receipt_document_id !== ''
|
||||
? validateId(req.body.receipt_document_id, 'receipt_document_id')
|
||||
: { value: null, error: null };
|
||||
const errors = collectErrors([vDate, vDailyRate, vExtras, vEventTitle, vPaymentTitle, vPaymentDescription, vReceiptId]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
if (vDailyRate.value < 0 || (vExtras.value ?? 0) < 0) {
|
||||
return res.status(400).json({ error: 'Amounts must be greater than or equal to zero.', code: 400 });
|
||||
}
|
||||
|
||||
const originalTime = existing.check_in?.slice(11) || '09:00:00.000Z';
|
||||
const checkIn = `${vDate.value}T${originalTime}`;
|
||||
const worker = existing.worker_id ? loadWorkerById(existing.worker_id) : null;
|
||||
db.get().transaction(() => {
|
||||
db.get().prepare(`
|
||||
UPDATE housekeeping_work_sessions
|
||||
SET check_in = ?, check_out = ?, daily_rate = ?, extras = ?, receipt_document_id = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
checkIn,
|
||||
checkIn,
|
||||
vDailyRate.value,
|
||||
vExtras.value ?? 0,
|
||||
req.body.receipt_document_id !== undefined ? vReceiptId.value : existing.receipt_document_id,
|
||||
existing.id,
|
||||
);
|
||||
updateVisitLinks(
|
||||
db.get(),
|
||||
existing,
|
||||
worker,
|
||||
checkIn,
|
||||
vDailyRate.value,
|
||||
vExtras.value ?? 0,
|
||||
vEventTitle.value,
|
||||
vPaymentTitle.value,
|
||||
vPaymentDescription.value,
|
||||
);
|
||||
})();
|
||||
const row = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(existing.id);
|
||||
res.json({ data: publicSession(row), summary: monthlySummary(row.check_in.slice(0, 7)) });
|
||||
} catch (err) {
|
||||
log.error('PUT /visits/:id error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/visits/:id/pay', (req, res) => {
|
||||
try {
|
||||
const vId = validateId(req.params.id, 'id');
|
||||
if (vId.error) return res.status(400).json({ error: vId.error, code: 400 });
|
||||
const existing = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(vId.value);
|
||||
if (!existing) return res.status(404).json({ error: 'Visit not found.', code: 404 });
|
||||
const paidAt = nowIso();
|
||||
db.get().transaction(() => {
|
||||
db.get().prepare('UPDATE housekeeping_work_sessions SET paid_at = ? WHERE id = ?').run(paidAt, existing.id);
|
||||
if (existing.payment_task_id) {
|
||||
db.get().prepare('UPDATE tasks SET status = ? WHERE id = ?').run('done', existing.payment_task_id);
|
||||
}
|
||||
})();
|
||||
const row = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(existing.id);
|
||||
res.json({ data: publicSession(row), summary: monthlySummary(row.check_in.slice(0, 7)) });
|
||||
} catch (err) {
|
||||
log.error('POST /visits/:id/pay error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/visits/:id', (req, res) => {
|
||||
try {
|
||||
const vId = validateId(req.params.id, 'id');
|
||||
if (vId.error) return res.status(400).json({ error: vId.error, code: 400 });
|
||||
const existing = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(vId.value);
|
||||
if (!existing) return res.status(404).json({ error: 'Visit not found.', code: 404 });
|
||||
db.get().transaction(() => {
|
||||
deleteVisitLinks(db.get(), existing);
|
||||
db.get().prepare('DELETE FROM housekeeping_work_sessions WHERE id = ?').run(existing.id);
|
||||
})();
|
||||
res.json({ ok: true, summary: monthlySummary() });
|
||||
} catch (err) {
|
||||
log.error('DELETE /visits/:id error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/work-sessions/check-out', (req, res) => {
|
||||
try {
|
||||
const vWorkerId = validateId(req.body.worker_id, 'worker_id');
|
||||
if (vWorkerId.error) return res.status(400).json({ error: vWorkerId.error, code: 400 });
|
||||
const session = loadOpenSession(vWorkerId.value);
|
||||
if (!session) return res.status(404).json({ error: 'No open work session found.', code: 404 });
|
||||
|
||||
const vExtras = num(req.body.extras, 'extras');
|
||||
if (vExtras.error) return res.status(400).json({ error: vExtras.error, code: 400 });
|
||||
if ((vExtras.value ?? session.extras) < 0) {
|
||||
return res.status(400).json({ error: 'Extras must be greater than or equal to zero.', code: 400 });
|
||||
}
|
||||
|
||||
const checkOut = nowIso();
|
||||
db.get().transaction(() => {
|
||||
db.get().prepare(`
|
||||
UPDATE housekeeping_work_sessions
|
||||
SET check_out = ?, extras = ?
|
||||
WHERE id = ?
|
||||
`).run(checkOut, vExtras.value ?? session.extras, session.id);
|
||||
})();
|
||||
const row = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(session.id);
|
||||
res.json({ data: publicSession(row), summary: monthlySummary(row.check_in.slice(0, 7)) });
|
||||
} catch (err) {
|
||||
log.error('POST /work-sessions/check-out error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/decay-tasks', (_req, res) => {
|
||||
try {
|
||||
const rows = db.get().prepare('SELECT * FROM housekeeping_decay_tasks ORDER BY area COLLATE NOCASE, name COLLATE NOCASE').all();
|
||||
const tasks = rows
|
||||
.map(publicDecayTask)
|
||||
.sort((a, b) => {
|
||||
const rank = { overdue: 0, today: 1, ok: 2 };
|
||||
const rankDiff = rank[a.urgency_status] - rank[b.urgency_status];
|
||||
if (rankDiff !== 0) return rankDiff;
|
||||
return (b.urgency ?? 9999) - (a.urgency ?? 9999);
|
||||
});
|
||||
res.json({ data: tasks });
|
||||
} catch (err) {
|
||||
log.error('GET /decay-tasks error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/decay-tasks', (req, res) => {
|
||||
try {
|
||||
const vName = str(req.body.name, 'name', { max: MAX_TITLE });
|
||||
const vArea = str(req.body.area, 'area', { max: MAX_SHORT });
|
||||
const vFrequency = num(req.body.frequency_days, 'frequency_days', { required: true });
|
||||
const vCompleted = datetime(req.body.last_completed, 'last_completed');
|
||||
const errors = collectErrors([vName, vArea, vFrequency, vCompleted]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
if (!Number.isInteger(vFrequency.value) || vFrequency.value < 1) {
|
||||
return res.status(400).json({ error: 'frequency_days must be a positive integer.', code: 400 });
|
||||
}
|
||||
|
||||
const result = db.get().prepare(`
|
||||
INSERT INTO housekeeping_decay_tasks (name, area, frequency_days, last_completed, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(vName.value, vArea.value, vFrequency.value, vCompleted.value, userId(req));
|
||||
const row = db.get().prepare('SELECT * FROM housekeeping_decay_tasks WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json({ data: publicDecayTask(row) });
|
||||
} catch (err) {
|
||||
log.error('POST /decay-tasks error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/decay-tasks/:taskId', (req, res) => {
|
||||
try {
|
||||
const vId = validateId(req.params.taskId, 'taskId');
|
||||
if (vId.error) return res.status(400).json({ error: vId.error, code: 400 });
|
||||
const existing = db.get().prepare('SELECT * FROM housekeeping_decay_tasks WHERE id = ?').get(vId.value);
|
||||
if (!existing) return res.status(404).json({ error: 'Task not found.', code: 404 });
|
||||
|
||||
const vName = req.body.name !== undefined ? str(req.body.name, 'name', { max: MAX_TITLE }) : { value: existing.name, error: null };
|
||||
const vArea = req.body.area !== undefined ? str(req.body.area, 'area', { max: MAX_SHORT }) : { value: existing.area, error: null };
|
||||
const vFrequency = req.body.frequency_days !== undefined ? num(req.body.frequency_days, 'frequency_days', { required: true }) : { value: existing.frequency_days, error: null };
|
||||
const vCompleted = req.body.last_completed !== undefined ? datetime(req.body.last_completed, 'last_completed') : { value: existing.last_completed, error: null };
|
||||
const errors = collectErrors([vName, vArea, vFrequency, vCompleted]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
if (!Number.isInteger(Number(vFrequency.value)) || Number(vFrequency.value) < 1) {
|
||||
return res.status(400).json({ error: 'frequency_days must be a positive integer.', code: 400 });
|
||||
}
|
||||
|
||||
db.get().prepare(`
|
||||
UPDATE housekeeping_decay_tasks
|
||||
SET name = ?, area = ?, frequency_days = ?, last_completed = ?
|
||||
WHERE id = ?
|
||||
`).run(vName.value, vArea.value, Number(vFrequency.value), vCompleted.value, vId.value);
|
||||
const row = db.get().prepare('SELECT * FROM housekeeping_decay_tasks WHERE id = ?').get(vId.value);
|
||||
res.json({ data: publicDecayTask(row) });
|
||||
} catch (err) {
|
||||
log.error('PATCH /decay-tasks/:taskId error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/decay-tasks/:taskId/complete', (req, res) => {
|
||||
try {
|
||||
const vId = validateId(req.params.taskId, 'taskId');
|
||||
if (vId.error) return res.status(400).json({ error: vId.error, code: 400 });
|
||||
const existing = db.get().prepare('SELECT * FROM housekeeping_decay_tasks WHERE id = ?').get(vId.value);
|
||||
if (!existing) return res.status(404).json({ error: 'Task not found.', code: 404 });
|
||||
|
||||
db.get().prepare('UPDATE housekeeping_decay_tasks SET last_completed = ? WHERE id = ?').run(nowIso(), vId.value);
|
||||
const row = db.get().prepare('SELECT * FROM housekeeping_decay_tasks WHERE id = ?').get(vId.value);
|
||||
res.json({ data: publicDecayTask(row) });
|
||||
} catch (err) {
|
||||
log.error('POST /decay-tasks/:taskId/complete error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/decay-tasks/:taskId', (req, res) => {
|
||||
try {
|
||||
const vId = validateId(req.params.taskId, 'taskId');
|
||||
if (vId.error) return res.status(400).json({ error: vId.error, code: 400 });
|
||||
const result = db.get().prepare('DELETE FROM housekeeping_decay_tasks WHERE id = ?').run(vId.value);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Task not found.', code: 404 });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
log.error('DELETE /decay-tasks/:taskId error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/supply-requests', (req, res) => {
|
||||
try {
|
||||
const vName = str(req.body.name, 'name', { max: MAX_TITLE });
|
||||
const vQuantity = str(req.body.quantity, 'quantity', { max: MAX_SHORT, required: false });
|
||||
const errors = collectErrors([vName, vQuantity]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
|
||||
const actorId = userId(req);
|
||||
const result = db.get().transaction(() => {
|
||||
const listId = defaultShoppingList(actorId);
|
||||
const item = db.get().prepare(`
|
||||
INSERT INTO shopping_items (list_id, name, quantity, category)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(listId, vName.value, vQuantity.value, defaultShoppingCategory());
|
||||
const request = db.get().prepare(`
|
||||
INSERT INTO housekeeping_supply_requests (name, quantity, shopping_item_id, created_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(vName.value, vQuantity.value, item.lastInsertRowid, actorId);
|
||||
return {
|
||||
requestId: request.lastInsertRowid,
|
||||
shoppingItemId: item.lastInsertRowid,
|
||||
};
|
||||
})();
|
||||
|
||||
const row = db.get().prepare('SELECT * FROM housekeeping_supply_requests WHERE id = ?').get(result.requestId);
|
||||
res.status(201).json({ data: row, shopping_item_id: result.shoppingItemId });
|
||||
} catch (err) {
|
||||
log.error('POST /supply-requests error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/maintenance-log', (_req, res) => {
|
||||
try {
|
||||
const rows = db.get().prepare('SELECT * FROM housekeeping_maintenance_log ORDER BY created_at DESC, id DESC').all();
|
||||
res.json({ data: rows });
|
||||
} catch (err) {
|
||||
log.error('GET /maintenance-log error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/maintenance-log', (req, res) => {
|
||||
try {
|
||||
const vDescription = str(req.body.description, 'description', { max: MAX_TEXT });
|
||||
const vPhoto = validatePhotoUrl(req.body.photo_url);
|
||||
const errors = collectErrors([vDescription, vPhoto]);
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
|
||||
const result = db.get().prepare(`
|
||||
INSERT INTO housekeeping_maintenance_log (description, photo_url, created_by)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(vDescription.value, vPhoto.value, userId(req));
|
||||
const row = db.get().prepare('SELECT * FROM housekeeping_maintenance_log WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json({ data: row });
|
||||
} catch (err) {
|
||||
log.error('POST /maintenance-log error:', err);
|
||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -144,6 +144,7 @@ router.get('/', (req, res) => {
|
||||
app_name: appName,
|
||||
dashboard_widgets: dashboardWidgets,
|
||||
disabled_modules: disabledModules,
|
||||
housekeeping_payment_tasks: cfgGet('housekeeping_payment_tasks') === '1',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -161,7 +162,7 @@ router.get('/', (req, res) => {
|
||||
|
||||
router.put('/', (req, res) => {
|
||||
try {
|
||||
const { visible_meal_types, currency, date_format, time_format, app_name, dashboard_widgets, disabled_modules } = req.body;
|
||||
const { visible_meal_types, currency, date_format, time_format, app_name, dashboard_widgets, disabled_modules, housekeeping_payment_tasks } = req.body;
|
||||
|
||||
if (visible_meal_types !== undefined) {
|
||||
if (!Array.isArray(visible_meal_types)) {
|
||||
@@ -220,6 +221,13 @@ router.put('/', (req, res) => {
|
||||
cfgSet('disabled_modules', JSON.stringify(unique));
|
||||
}
|
||||
|
||||
if (housekeeping_payment_tasks !== undefined) {
|
||||
if (typeof housekeeping_payment_tasks !== 'boolean') {
|
||||
return res.status(400).json({ error: 'housekeeping_payment_tasks must be a boolean', code: 400 });
|
||||
}
|
||||
cfgSet('housekeeping_payment_tasks', housekeeping_payment_tasks ? '1' : '0');
|
||||
}
|
||||
|
||||
const rawMealTypes = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
|
||||
const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
|
||||
const savedCurrency = cfgGet('currency') ?? DEFAULT_CURRENCY;
|
||||
@@ -228,6 +236,7 @@ router.put('/', (req, res) => {
|
||||
const savedAppName = cfgGet('app_name') ?? DEFAULT_APP_NAME;
|
||||
const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
|
||||
const savedDisabledModules = parseDisabledModules(cfgGet('disabled_modules'));
|
||||
const savedHousekeepingPaymentTasks = cfgGet('housekeeping_payment_tasks') === '1';
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
@@ -238,6 +247,7 @@ router.put('/', (req, res) => {
|
||||
app_name: savedAppName,
|
||||
dashboard_widgets: savedWidgets,
|
||||
disabled_modules: savedDisabledModules,
|
||||
housekeeping_payment_tasks: savedHousekeepingPaymentTasks,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -53,6 +53,19 @@ function setAssignments(d, taskId, userIds) {
|
||||
for (const uid of userIds) ins.run(taskId, uid);
|
||||
}
|
||||
|
||||
function syncHousekeepingPaymentStatus(d, taskId, status) {
|
||||
const table = d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'housekeeping_work_sessions'").get();
|
||||
if (!table) return;
|
||||
d.prepare(`
|
||||
UPDATE housekeeping_work_sessions
|
||||
SET paid_at = CASE
|
||||
WHEN ? = 'done' THEN COALESCE(paid_at, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE payment_task_id = ?
|
||||
`).run(status, taskId);
|
||||
}
|
||||
|
||||
/** Alle Subtasks einer Aufgabe laden (eine Ebene tief). */
|
||||
function loadSubtasks(taskId) {
|
||||
return db.get().prepare(`
|
||||
@@ -274,6 +287,7 @@ router.put('/:id', (req, res) => {
|
||||
status, due_date, due_time, firstUid,
|
||||
is_recurring ? 1 : 0, recurrence_rule, req.params.id);
|
||||
setAssignments(db.get(), task.id, userIds);
|
||||
syncHousekeepingPaymentStatus(db.get(), req.params.id, status);
|
||||
})();
|
||||
|
||||
const updated = db.get().prepare(`
|
||||
@@ -310,6 +324,8 @@ router.patch('/:id/status', (req, res) => {
|
||||
if (result.changes === 0)
|
||||
return res.status(404).json({ error: 'Task not found.', code: 404 });
|
||||
|
||||
syncHousekeepingPaymentStatus(db.get(), req.params.id, status);
|
||||
|
||||
// Wiederkehrende Aufgabe: nächste Instanz erstellen wenn erledigt
|
||||
if (status === 'done') {
|
||||
const task = db.get().prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
|
||||
|
||||
@@ -57,8 +57,7 @@ function getOffsetMinutes(birthday) {
|
||||
|
||||
function birthdayReminderAt(birthDate, offsetMin = 0, from = new Date()) {
|
||||
const next = nextBirthdayDate(birthDate, from);
|
||||
const baseTime = new Date(`${next}T12:00:00Z`).getTime();
|
||||
return new Date(baseTime - (offsetMin || 0) * 60000).toISOString();
|
||||
return `${next}T12:00:00Z`;
|
||||
}
|
||||
|
||||
function eventTitle(name) {
|
||||
|
||||
Reference in New Issue
Block a user