UNPKG

@debito/hippo-lib

Version:

Double-entry accounting library for CouchDB

321 lines (287 loc) â€ĸ 10.5 kB
import Account from './Account.js'; import LedgerEntry from './LedgerEntry.js'; import JournalEntry from './JournalEntry.js'; import COA from './coa.js'; import TrialBalance from './reports/TrialBalance.js'; import LedgerTemplate from './helpers/LedgerTemplate.js'; import JournalTemplate from './helpers/JournalTemplate.js'; import config, { getDB, getMetaDB, initMetadataConnection, switchToBook, setUserProfile, getUserProfile } from './config.js'; import Book from './metadata/Book.js'; import User from './metadata/User.js'; import COATemplate from './COATemplate.js'; import Check from './Check.js'; import Upload, { UPLOAD_STATUS } from './Upload.js'; import { CHECK_STATUS } from './constants.js'; import ErrorLog from './ErrorLog.js'; /** * Verify user has access to a book via user_book relationship (for hippo UI) * @param {string} userId - User identifier * @param {string} bookId - Book identifier * @returns {Promise<Object>} User-book relationship object */ async function verifyUserBookAccess(userId, bookId) { const metaDB = getMetaDB(); try { // Query for user-book relationship by userId and bookId const result = await metaDB.find({ selector: { type: 'user_book', userId: userId, bookId: bookId }, limit: 1 }); if (result.docs.length === 0) { throw new Error(`Access denied: User ${userId} does not have access to book ${bookId}`); } return result.docs[0]; } catch (error) { if (error.message.includes('Access denied')) { throw error; } throw new Error(`Failed to verify book access: ${error.message}`); } } /** * Verify organization-level access to a book (for backend apps like VM, CF) * Checks that the book's organizationId matches the caller's organizationId * @param {string} bookId - Book identifier * @returns {Promise<Object>} Synthetic access object with editor role */ async function verifyOrgBookAccess(bookId) { const metaDB = getMetaDB(); const profile = getUserProfile(); if (!profile?.organizationId) { throw new Error('Organization access requires organizationId in userProfile'); } try { const bookDoc = await metaDB.get(bookId); if (!bookDoc.organizationId) { throw new Error(`Book ${bookId} is not organization-bound`); } if (bookDoc.organizationId !== profile.organizationId) { throw new Error(`Access denied: Organization mismatch for book ${bookId}`); } // Return synthetic access object matching user_book shape return { type: 'org_access', userId: profile.userId, bookId: bookId, organizationId: bookDoc.organizationId, role: 'editor', permissions: ['read', 'write'] }; } catch (error) { if (error.message.includes('Access denied') || error.message.includes('not organization-bound') || error.message.includes('Organization access requires')) { throw error; } if (error.status === 404) { throw new Error(`Book ${bookId} not found`); } throw new Error(`Failed to verify org book access: ${error.message}`); } } /** * Initialize metadata database indexes * @param {CouchDBDriver} metaDB - Metadata database driver instance */ async function initializeMetadataIndexes(metaDB) { console.log('🔧 Initializing metadata indexes...'); try { const indexes = [ { index: { fields: ['type', 'userId'] }, name: 'user-books-by-user' }, { index: { fields: ['type', 'bookId'] }, name: 'user-books-by-book' }, { index: { fields: ['type', 'userId', 'joinedAt'] }, name: 'user-books-by-user-date' }, { index: { fields: ['type', 'name'] }, name: 'coa-templates-by-name' }, { index: { fields: ['type', 'bookId', 'role', 'joinedAt'] }, name: 'user-books-by-book-role-date' }, { index: { fields: ['type', 'userId', 'bookId'] }, name: 'user-books-by-user-book' } ]; for (const indexDef of indexes) { try { await metaDB.createIndex({ ...indexDef, type: 'json' }); console.log(`✅ Created metadata index: ${indexDef.name}`); } catch (error) { if (error.error === 'file_exists') { console.log(`â„šī¸ Metadata index already exists: ${indexDef.name}`); } else { throw error; } } } console.log('🎉 Metadata indexes initialization completed!'); } catch (error) { console.error('❌ Metadata indexes initialization failed:', error.message); throw error; } } /** * Switch to a different book * @param {string} bookId - Book identifier * @param {string} userId - User identifier (for access verification) * @param {Object} [options] - Options * @param {string} [options.accessMode] - 'user' for explicit user_book access (hippo UI). Otherwise auto-detects: org access if organizationId present, user_book fallback if not. * @returns {Promise<void>} */ async function switchBook(bookId, userId, options = {}) { if (!bookId) { throw new Error('switchBook() requires bookId'); } if (!userId) { throw new Error('switchBook() requires userId for access verification'); } // Verify access based on mode // If accessMode is 'user' → explicit user_book check (hippo UI) // If organizationId present (and not 'user' mode) → org access (default for backend apps) // Otherwise → fall back to user_book check if (options.accessMode === 'user') { await verifyUserBookAccess(userId, bookId); } else if (getUserProfile()?.organizationId) { await verifyOrgBookAccess(bookId); } else { await verifyUserBookAccess(userId, bookId); } // Switch to book database switchToBook(bookId); // Check if book database exists const bookDB = getDB(); try { await bookDB.databaseInfo(); } catch (error) { if (error.status === 404) { throw new Error(`Book database '${bookId}' does not exist. Books should be created through User.createBook() method.`); } else { throw error; } } console.log(`✅ Switched to book: ${bookId}`); } /** * Initialize hippo-lib * @param {Object} userProfile - User profile * @param {string} userProfile.userId - User identifier * @param {string} [userProfile.organizationId] - Organization identifier (for org-bound access) * @param {Object} serverProfile - Server connection details * @param {string} serverProfile.username - CouchDB username * @param {string} serverProfile.password - CouchDB password * @param {string} serverProfile.url - CouchDB server URL * @param {string} [bookId] - Optional book identifier * @param {Object} [options] - Options * @param {string} [options.accessMode] - 'user' for explicit user_book access (hippo UI). Otherwise auto-detects from userProfile.organizationId. * @returns {Promise<void>} */ async function init(userProfile, serverProfile, bookId, options = {}) { if (!userProfile?.userId) { throw new Error('hippo-lib.init() requires userProfile.userId'); } if (!serverProfile?.username || !serverProfile?.password || !serverProfile?.url) { throw new Error('hippo-lib.init() requires serverProfile with username, password, and url'); } console.log(`🔑 hippo-lib.init() userProfile:`, JSON.stringify(userProfile), `bookId: ${bookId || '(none)'}`, `options:`, JSON.stringify(options)); // Store user profile globally setUserProfile(userProfile); // Initialize metadata database connection initMetadataConnection(serverProfile); // Check if metadata database exists, create if it doesn't const metaDB = getMetaDB(); try { await metaDB.databaseInfo(); } catch (error) { if (error.status === 404) { console.log('🆕 Creating metadata database: hippo-meta'); await metaDB.createDatabase('hippo-meta'); } else { throw error; } } // Rebuild indexes if requested if (options.rebuildIndexes) { await initializeMetadataIndexes(metaDB); } console.log('✅ hippo-lib metadata initialized'); // Switch to book if bookId provided, or auto-discover org book if (bookId) { await switchBook(bookId, userProfile.userId, options); } else if (userProfile.organizationId && options.accessMode !== 'user') { // Auto-discover org book (prefer default book) const orgBooks = await Book.listOrgBooks(userProfile.organizationId); if (orgBooks.length > 0) { const defaultBook = orgBooks.find(b => b.isDefault) || orgBooks[0]; console.log(`📖 Auto-discovered org book: ${defaultBook._id} (default: ${!!defaultBook.isDefault})`); await switchBook(defaultBook._id, userProfile.userId, options); } else { console.log('â„šī¸ No org book found, metadata-only mode'); } } else { console.log('â„šī¸ No book selected. Use switchBook() to select a book for accounting operations.'); } } /** * Get database information * @returns {Promise<Object>} Database info from CouchDB */ async function getDatabaseInfo() { const db = getDB(); return await db.databaseInfo(); } export { init, switchBook, getDatabaseInfo, User, Book, Account, LedgerEntry, JournalEntry, Check, CHECK_STATUS, Upload, UPLOAD_STATUS, COA, COATemplate, TrialBalance, LedgerTemplate, JournalTemplate, ErrorLog }; // Default export for convenience export default { init, switchBook, getDatabaseInfo, User, Book, Account, LedgerEntry, JournalEntry, Check, CHECK_STATUS, Upload, UPLOAD_STATUS, COA, COATemplate, TrialBalance, LedgerTemplate, JournalTemplate, ErrorLog };