feat(documents): add family document management

This commit is contained in:
Rafael Foster
2026-04-29 06:14:29 -03:00
parent 6eafe80395
commit 72fca92066
24 changed files with 1927 additions and 33 deletions
+39
View File
@@ -380,6 +380,45 @@ const MIGRATIONS_SQL = {
CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to);
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id);
`,
19: `
CREATE TABLE IF NOT EXISTS family_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT 'other'
CHECK(category IN ('medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other')),
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active', 'archived')),
visibility TEXT NOT NULL DEFAULT 'family'
CHECK(visibility IN ('family', 'restricted', 'private')),
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
file_size INTEGER NOT NULL,
content_data TEXT NOT NULL,
storage_provider TEXT NOT NULL DEFAULT 'local'
CHECK(storage_provider IN ('local', 'external')),
storage_key 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 family_document_access (
document_id INTEGER NOT NULL REFERENCES family_documents(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
PRIMARY KEY (document_id, user_id)
);
CREATE TRIGGER IF NOT EXISTS trg_family_documents_updated_at
AFTER UPDATE ON family_documents FOR EACH ROW
BEGIN UPDATE family_documents SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
CREATE INDEX IF NOT EXISTS idx_family_documents_status ON family_documents(status);
CREATE INDEX IF NOT EXISTS idx_family_documents_category ON family_documents(category);
CREATE INDEX IF NOT EXISTS idx_family_documents_created_by ON family_documents(created_by);
CREATE INDEX IF NOT EXISTS idx_family_document_access_user ON family_document_access(user_id);
`,
};
export { MIGRATIONS_SQL };
+43
View File
@@ -810,6 +810,49 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id);
`,
},
{
version: 26,
description: 'Family documents with local storage metadata and visibility ACL',
up: `
CREATE TABLE IF NOT EXISTS family_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT 'other'
CHECK(category IN ('medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other')),
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active', 'archived')),
visibility TEXT NOT NULL DEFAULT 'family'
CHECK(visibility IN ('family', 'restricted', 'private')),
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
file_size INTEGER NOT NULL,
content_data TEXT NOT NULL,
storage_provider TEXT NOT NULL DEFAULT 'local'
CHECK(storage_provider IN ('local', 'external')),
storage_key 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 family_document_access (
document_id INTEGER NOT NULL REFERENCES family_documents(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
PRIMARY KEY (document_id, user_id)
);
CREATE TRIGGER IF NOT EXISTS trg_family_documents_updated_at
AFTER UPDATE ON family_documents FOR EACH ROW
BEGIN UPDATE family_documents SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
CREATE INDEX IF NOT EXISTS idx_family_documents_status ON family_documents(status);
CREATE INDEX IF NOT EXISTS idx_family_documents_category ON family_documents(category);
CREATE INDEX IF NOT EXISTS idx_family_documents_created_by ON family_documents(created_by);
CREATE INDEX IF NOT EXISTS idx_family_document_access_user ON family_document_access(user_id);
`,
},
];
/**
+2
View File
@@ -27,6 +27,7 @@ import notesRouter from './routes/notes.js';
import contactsRouter from './routes/contacts.js';
import birthdaysRouter from './routes/birthdays.js';
import budgetRouter from './routes/budget.js';
import documentsRouter from './routes/documents.js';
import weatherRouter from './routes/weather.js';
import preferencesRouter from './routes/preferences.js';
import remindersRouter from './routes/reminders.js';
@@ -200,6 +201,7 @@ app.use('/api/v1/notes', notesRouter);
app.use('/api/v1/contacts', contactsRouter);
app.use('/api/v1/birthdays', birthdaysRouter);
app.use('/api/v1/budget', budgetRouter);
app.use('/api/v1/documents', documentsRouter);
app.use('/api/v1/weather', weatherRouter);
app.use('/api/v1/preferences', preferencesRouter);
app.use('/api/v1/reminders', remindersRouter);
+262
View File
@@ -0,0 +1,262 @@
/**
* Module: Family Documents
* Purpose: REST API for locally stored family documents with per-member visibility.
* Dependencies: express, server/db.js
*/
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';
const log = createLogger('Documents');
const router = express.Router();
const CATEGORIES = ['medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other'];
const VISIBILITIES = ['family', 'restricted', 'private'];
const STATUSES = ['active', 'archived'];
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const ALLOWED_MIME = new Set([
'application/pdf',
'image/png',
'image/jpeg',
'image/webp',
'text/plain',
'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]);
function userId(req) {
return req.authUserId || req.session.userId;
}
function isAdmin(req) {
return req.authRole === 'admin' || req.session?.role === 'admin';
}
function canSeeSql(alias = 'd') {
return `(
${alias}.created_by = @userId
OR ${alias}.visibility = 'family'
OR EXISTS (
SELECT 1 FROM family_document_access a
WHERE a.document_id = ${alias}.id AND a.user_id = @userId
)
)`;
}
function parseMemberIds(value) {
if (!Array.isArray(value)) return [];
return [...new Set(value.map((id) => Number(id)).filter((id) => Number.isInteger(id) && id > 0))];
}
function parseDataUrl(dataUrl) {
const raw = String(dataUrl || '');
const match = raw.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/);
if (!match) return { error: 'File content must be a valid base64 data URL.' };
const mime = match[1].toLowerCase();
if (!ALLOWED_MIME.has(mime)) return { error: 'File type is not allowed.' };
const base64 = match[2].replace(/\s/g, '');
const buffer = Buffer.from(base64, 'base64');
if (!buffer.length) return { error: 'File content is empty.' };
if (buffer.length > MAX_FILE_BYTES) return { error: 'File may be at most 5 MB.' };
return { mime, base64, size: buffer.length, buffer };
}
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,
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 users u ON u.id = d.created_by
LEFT JOIN family_document_access a ON a.document_id = d.id
`;
}
function normalizeDocument(row) {
if (!row) return null;
return {
...row,
allowed_member_ids: row.allowed_member_ids
? row.allowed_member_ids.split(',').map((id) => Number(id)).filter(Boolean)
: [],
};
}
function getVisibleDocument(id, req, includeContent = false) {
const columns = includeContent ? 'd.*' : 'd.id, d.created_by, d.visibility, d.description';
return db.get().prepare(`
SELECT ${columns}
FROM family_documents d
WHERE d.id = @id AND ${canSeeSql('d')}
`).get({ id, userId: userId(req) });
}
function replaceAccess(documentId, memberIds) {
const database = db.get();
database.prepare('DELETE FROM family_document_access WHERE document_id = ?').run(documentId);
const insert = database.prepare('INSERT OR IGNORE INTO family_document_access (document_id, user_id) VALUES (?, ?)');
for (const memberId of memberIds) insert.run(documentId, memberId);
}
router.get('/meta/options', (_req, res) => {
res.json({
data: {
categories: CATEGORIES,
visibilities: VISIBILITIES,
statuses: STATUSES,
max_file_size: MAX_FILE_BYTES,
allowed_mime_types: Array.from(ALLOWED_MIME),
storage_providers: ['local'],
},
});
});
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 rows = db.get().prepare(`
${documentSelect()}
WHERE ${canSeeSql('d')}
AND d.status = @status
AND (@category IS NULL OR d.category = @category)
GROUP BY d.id
ORDER BY d.updated_at DESC
`).all(params);
res.json({ data: rows.map(normalizeDocument) });
} catch (err) {
log.error('GET / error:', err);
res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
router.post('/', (req, res) => {
try {
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]);
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 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 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));
if (visibility === 'restricted') replaceAccess(result.lastInsertRowid, allowedIds);
const row = database.prepare(`
${documentSelect()}
WHERE d.id = ?
GROUP BY d.id
`).get(result.lastInsertRowid);
res.status(201).json({ data: normalizeDocument(row) });
} catch (err) {
log.error('POST / error:', err);
res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
router.put('/:id', (req, res) => {
try {
const id = Number(req.params.id);
const existing = getVisibleDocument(id, req);
if (!existing) return res.status(404).json({ error: 'Document not found.', code: 404 });
if (existing.created_by !== userId(req) && !isAdmin(req)) return res.status(403).json({ error: 'Not authorized.', code: 403 });
const vName = req.body.name !== undefined ? str(req.body.name, 'Name', { max: MAX_TITLE }) : { value: null };
const vDescription = req.body.description !== undefined ? str(req.body.description, 'Description', { max: MAX_TEXT, required: false }) : { value: null };
const errors = collectErrors([vName, vDescription]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
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;
db.get().prepare(`
UPDATE family_documents
SET name = COALESCE(?, name),
description = ?,
category = COALESCE(?, category),
visibility = COALESCE(?, visibility),
status = COALESCE(?, status)
WHERE id = ?
`).run(
req.body.name !== undefined ? vName.value : null,
req.body.description !== undefined ? vDescription.value : existing.description,
category,
visibility,
status,
id
);
if ((visibility || existing.visibility) === 'restricted') replaceAccess(id, parseMemberIds(req.body.allowed_member_ids));
else replaceAccess(id, []);
const row = db.get().prepare(`${documentSelect()} WHERE d.id = ? GROUP BY d.id`).get(id);
res.json({ data: normalizeDocument(row) });
} catch (err) {
log.error('PUT /:id error:', err);
res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
router.patch('/:id/archive', (req, res) => {
try {
const id = Number(req.params.id);
const existing = getVisibleDocument(id, req);
if (!existing) return res.status(404).json({ error: 'Document not found.', code: 404 });
if (existing.created_by !== userId(req) && !isAdmin(req)) return res.status(403).json({ error: 'Not authorized.', code: 403 });
const status = req.body.archived === false ? 'active' : 'archived';
db.get().prepare('UPDATE family_documents SET status = ? WHERE id = ?').run(status, id);
res.json({ data: { id, status } });
} catch (err) {
log.error('PATCH /:id/archive error:', err);
res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
router.get('/:id/download', (req, res) => {
try {
const id = Number(req.params.id);
const doc = getVisibleDocument(id, req, true);
if (!doc) return res.status(404).json({ error: 'Document not found.', code: 404 });
const filename = encodeURIComponent(doc.original_name.replace(/[/\\]/g, '_'));
res.setHeader('Content-Type', doc.mime_type);
res.setHeader('Content-Length', String(doc.file_size));
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.end(Buffer.from(doc.content_data, 'base64'));
} catch (err) {
log.error('GET /:id/download error:', err);
res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
router.delete('/:id', (req, res) => {
try {
const id = Number(req.params.id);
const existing = getVisibleDocument(id, req);
if (!existing) return res.status(404).json({ error: 'Document not found.', code: 404 });
if (existing.created_by !== userId(req) && !isAdmin(req)) return res.status(403).json({ error: 'Not authorized.', code: 403 });
db.get().prepare('DELETE FROM family_documents WHERE id = ?').run(id);
res.status(204).end();
} catch (err) {
log.error('DELETE /:id error:', err);
res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
export default router;