feat(documents): add family document management
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user