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:
Rafael Foster
2026-05-08 15:14:51 -03:00
committed by GitHub
parent d19689a1ab
commit 22ec13e559
38 changed files with 7127 additions and 235 deletions
+41 -3
View File
@@ -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
);
+68 -9
View File
@@ -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));
+3
View File
@@ -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 });
+956
View File
@@ -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;
+11 -1
View File
@@ -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) {
+16
View File
@@ -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);