const express = require('express'); const path = require('path'); const sqlite3 = require('sqlite3').verbose(); const app = express(); const PORT = process.env.PORT || 8085; const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'crm.db'); // Middleware app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); // Connect to SQLite Database const db = new sqlite3.Database(DB_PATH, (err) => { if (err) { console.error('Error connecting to SQLite database:', err.message); } else { console.log('Connected to SQLite database at:', DB_PATH); initializeDatabase(); } }); // Initialize Database Tables function initializeDatabase() { db.serialize(() => { // Friends Table db.run(` CREATE TABLE IF NOT EXISTS friends ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, birthday TEXT, contact TEXT, address TEXT, relationship_status TEXT, family TEXT, honor INTEGER DEFAULT 0, life_situation TEXT, job TEXT, hobbies TEXT, milestones TEXT, food_preferences TEXT, random_notes TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); // Meetings Table db.run(` CREATE TABLE IF NOT EXISTS meetings ( id INTEGER PRIMARY KEY AUTOINCREMENT, friend_id INTEGER NOT NULL, date TEXT NOT NULL, activity TEXT, mood TEXT, details TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(friend_id) REFERENCES friends(id) ON DELETE CASCADE ) `); // Topics Table db.run(` CREATE TABLE IF NOT EXISTS topics ( id INTEGER PRIMARY KEY AUTOINCREMENT, friend_id INTEGER NOT NULL, topic TEXT NOT NULL, completed INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(friend_id) REFERENCES friends(id) ON DELETE CASCADE ) `); // Seed Initial Data (Aaron Lingel) if database is empty db.get("SELECT COUNT(*) as count FROM friends", (err, row) => { if (err) return console.error('Error checking friends count:', err.message); if (row.count === 0) { console.log('Seeding initial data...'); const stmt = db.prepare(` INSERT INTO friends ( name, birthday, contact, address, relationship_status, family, honor, life_situation, job, hobbies, milestones, food_preferences, random_notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( "Aaron Lingel", "1999-08-13", "+49 172 1821612", "Am schlaggraben 18\n71272 renningen", "FOREVER ALONE (aber hat mich)", "Mama Lingel und Papa Africano", 32, "Auf der Suche nach neuen Abenteuern", "Studium / Arbeit", "Ehrenbruder sein, D&D", "Hat ein stabiles Freundschaftsprofil bekommen", "Gutes Essen", "Bester Kumpel" ); stmt.finalize(); // Seed an initial meeting log for Aaron db.get("SELECT id FROM friends WHERE name = 'Aaron Lingel'", (err, row) => { if (row) { db.run(` INSERT INTO meetings (friend_id, date, activity, mood, details) VALUES (?, ?, ?, ?, ?) `, [row.id, "2026-05-20", "Gemütliches Kaltgetränk gezischt", "Strahlt vor Freude", "Lustige Gespräche über Gott und die Welt geführt. Aaron ist hochmotiviert für neue Projekte."]); db.run(` INSERT INTO topics (friend_id, topic, completed) VALUES (?, ?, ?) `, [row.id, "Nächstes D&D Abenteuer planen", 0]); db.run(` INSERT INTO topics (friend_id, topic, completed) VALUES (?, ?, ?) `, [row.id, "Seinen Ehren-Counter im CRM feiern", 0]); } }); } }); }); } // API ENDPOINTS // 1. Get all friends (with their last meeting date) app.get('/api/friends', (req, res) => { const query = ` SELECT f.*, MAX(m.date) as last_meeting_date, (SELECT COUNT(*) FROM topics t WHERE t.friend_id = f.id AND t.completed = 0) as pending_topics_count FROM friends f LEFT JOIN meetings m ON f.id = m.friend_id GROUP BY f.id ORDER BY f.name ASC `; db.all(query, [], (err, rows) => { if (err) { return res.status(500).json({ error: err.message }); } res.json(rows); }); }); // 2. Get single friend details (including meetings & topics) app.get('/api/friends/:id', (req, res) => { const friendId = req.params.id; db.get("SELECT * FROM friends WHERE id = ?", [friendId], (err, friend) => { if (err) return res.status(500).json({ error: err.message }); if (!friend) return res.status(404).json({ error: 'Friend not found' }); db.all("SELECT * FROM meetings WHERE friend_id = ? ORDER BY date DESC", [friendId], (err, meetings) => { if (err) return res.status(500).json({ error: err.message }); db.all("SELECT * FROM topics WHERE friend_id = ? ORDER BY completed ASC, created_at DESC", [friendId], (err, topics) => { if (err) return res.status(500).json({ error: err.message }); res.json({ ...friend, meetings: meetings || [], topics: topics || [] }); }); }); }); }); // 3. Create a new friend app.post('/api/friends', (req, res) => { const { name, birthday, contact, address, relationship_status, family, honor, life_situation, job, hobbies, milestones, food_preferences, random_notes } = req.body; if (!name) { return res.status(400).json({ error: 'Name is required' }); } const query = ` INSERT INTO friends ( name, birthday, contact, address, relationship_status, family, honor, life_situation, job, hobbies, milestones, food_preferences, random_notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `; const params = [ name, birthday || '', contact || '', address || '', relationship_status || '', family || '', honor || 0, life_situation || '', job || '', hobbies || '', milestones || '', food_preferences || '', random_notes || '' ]; db.run(query, params, function(err) { if (err) { return res.status(500).json({ error: err.message }); } res.status(201).json({ id: this.lastID, message: 'Friend created successfully' }); }); }); // 4. Update friend details app.put('/api/friends/:id', (req, res) => { const friendId = req.params.id; const { name, birthday, contact, address, relationship_status, family, honor, life_situation, job, hobbies, milestones, food_preferences, random_notes } = req.body; if (!name) { return res.status(400).json({ error: 'Name is required' }); } const query = ` UPDATE friends SET name = ?, birthday = ?, contact = ?, address = ?, relationship_status = ?, family = ?, honor = ?, life_situation = ?, job = ?, hobbies = ?, milestones = ?, food_preferences = ?, random_notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `; const params = [ name, birthday, contact, address, relationship_status, family, honor, life_situation, job, hobbies, milestones, food_preferences, random_notes, friendId ]; db.run(query, params, function(err) { if (err) { return res.status(500).json({ error: err.message }); } if (this.changes === 0) { return res.status(404).json({ error: 'Friend not found' }); } res.json({ message: 'Friend updated successfully' }); }); }); // 5. Delete a friend app.delete('/api/friends/:id', (req, res) => { const friendId = req.params.id; db.run("DELETE FROM friends WHERE id = ?", [friendId], function(err) { if (err) { return res.status(500).json({ error: err.message }); } res.json({ message: 'Friend deleted successfully' }); }); }); // 6. Update friend's honor score (+/-) app.post('/api/friends/:id/honor', (req, res) => { const friendId = req.params.id; const { change } = req.body; // should be +1 or -1 if (change !== 1 && change !== -1) { return res.status(400).json({ error: 'Invalid honor change value. Must be 1 or -1.' }); } db.run("UPDATE friends SET honor = honor + ? WHERE id = ?", [change, friendId], function(err) { if (err) { return res.status(500).json({ error: err.message }); } if (this.changes === 0) { return res.status(404).json({ error: 'Friend not found' }); } // Retrieve the updated honor value to send back db.get("SELECT honor FROM friends WHERE id = ?", [friendId], (err, row) => { if (err) return res.status(500).json({ error: err.message }); res.json({ honor: row.honor, message: 'Honor updated successfully' }); }); }); }); // 6.5. Get all meetings (global list, sorted by date descending) app.get('/api/meetings', (req, res) => { const query = ` SELECT m.*, f.name as friend_name FROM meetings m JOIN friends f ON m.friend_id = f.id ORDER BY m.date DESC `; db.all(query, [], (err, rows) => { if (err) { return res.status(500).json({ error: err.message }); } res.json(rows); }); }); // 7. Log a new meeting app.post('/api/meetings', (req, res) => { const { friend_id, date, activity, mood, details } = req.body; if (!friend_id || !date) { return res.status(400).json({ error: 'Friend ID and Date are required' }); } const query = ` INSERT INTO meetings (friend_id, date, activity, mood, details) VALUES (?, ?, ?, ?, ?) `; db.run(query, [friend_id, date, activity || '', mood || '', details || ''], function(err) { if (err) { return res.status(500).json({ error: err.message }); } res.status(201).json({ id: this.lastID, message: 'Meeting logged successfully' }); }); }); // 8. Delete a meeting app.delete('/api/meetings/:id', (req, res) => { const meetingId = req.params.id; db.run("DELETE FROM meetings WHERE id = ?", [meetingId], function(err) { if (err) { return res.status(500).json({ error: err.message }); } res.json({ message: 'Meeting deleted successfully' }); }); }); // 9. Add a topic to discuss app.post('/api/topics', (req, res) => { const { friend_id, topic } = req.body; if (!friend_id || !topic) { return res.status(400).json({ error: 'Friend ID and Topic description are required' }); } db.run("INSERT INTO topics (friend_id, topic) VALUES (?, ?)", [friend_id, topic], function(err) { if (err) { return res.status(500).json({ error: err.message }); } res.status(201).json({ id: this.lastID, message: 'Topic added successfully' }); }); }); // 10. Toggle topic completion status app.put('/api/topics/:id', (req, res) => { const topicId = req.params.id; const { completed } = req.body; // 0 or 1 db.run("UPDATE topics SET completed = ? WHERE id = ?", [completed ? 1 : 0, topicId], function(err) { if (err) { return res.status(500).json({ error: err.message }); } res.json({ message: 'Topic updated successfully' }); }); }); // 11. Delete a topic app.delete('/api/topics/:id', (req, res) => { const topicId = req.params.id; db.run("DELETE FROM topics WHERE id = ?", [topicId], function(err) { if (err) { return res.status(500).json({ error: err.message }); } res.json({ message: 'Topic deleted successfully' }); }); }); // 12. Smart Import from Obsidian Markdown Content app.post('/api/import-obsidian', (req, res) => { const { filename, content } = req.body; if (!content) { return res.status(400).json({ error: 'Markdown content is required' }); } try { // 1. Extract name from filename or title let name = filename ? filename.replace(/\.md$/, '').trim() : ''; // 2. Parse YAML frontmatter if exists const yamlRegex = /^---([\s\S]*?)---/; const yamlMatch = content.match(yamlRegex); let frontmatter = {}; let markdownBody = content; if (yamlMatch) { markdownBody = content.replace(yamlMatch[0], ''); const yamlContent = yamlMatch[1]; yamlContent.split('\n').forEach(line => { const parts = line.split(':'); if (parts.length >= 2) { const key = parts[0].trim(); const val = parts.slice(1).join(':').trim(); frontmatter[key] = val; } }); } // 3. Simple parser for markdown sections // E.g. we find sections like ## Allgemeines, ## Aktuelle Lebenssituation, etc. const sections = {}; const headingRegex = /^##\s+(.+)$/gm; let match; const headings = []; // Get all ## headings and their indexes while ((match = headingRegex.exec(markdownBody)) !== null) { headings.push({ title: match[1].trim().toLowerCase(), index: match.index, fullHeading: match[0] }); } for (let i = 0; i < headings.length; i++) { const start = headings[i].index + headings[i].fullHeading.length; const end = (i + 1 < headings.length) ? headings[i + 1].index : markdownBody.length; const sectionText = markdownBody.slice(start, end).trim(); sections[headings[i].title] = sectionText; } // Parse 'allgemeines' list items const generalText = sections['allgemeines'] || ''; let birthday = ''; let contact = ''; let address = ''; let relationship_status = ''; let family = ''; let honor = 0; generalText.split('\n').forEach(line => { const cleaned = line.replace(/^-\s+\*\*/, '').replace(/^-/, '').trim(); if (cleaned.startsWith('Name:')) { const parsedName = cleaned.replace('Name:', '').trim(); if (parsedName) name = parsedName; } else if (cleaned.startsWith('Geburtstag:')) { let bday = cleaned.replace('Geburtstag:', '').trim(); // Convert DD.MM.YYYY to YYYY-MM-DD const dateMatch = bday.match(/(\d{2})\.(\d{2})\.(\d{4})/); if (dateMatch) { birthday = `${dateMatch[3]}-${dateMatch[2]}-${dateMatch[1]}`; } else { birthday = bday; } } else if (cleaned.startsWith('Kontakt:')) { contact = cleaned.replace('Kontakt:', '').trim(); } else if (cleaned.startsWith('Wohnort:')) { address = cleaned.replace('Wohnort:', '').trim(); } else if (cleaned.startsWith('Beziehungsstatus:')) { relationship_status = cleaned.replace('Beziehungsstatus:', '').trim(); } else if (cleaned.startsWith('Familie:')) { family = cleaned.replace('Familie:', '').trim(); } else if (cleaned.startsWith('Ehre:')) { const honorStr = cleaned.replace('Ehre:', '').trim(); const parsedHonor = parseInt(honorStr.replace('+', ''), 10); if (!isNaN(parsedHonor)) honor = parsedHonor; } }); if (!name) { name = 'Unbekannter Freund'; } // Parse other sections const life_situation = sections['aktuelle lebenssituation'] || ''; // Split job, hobbies, milestones let job = ''; let hobbies = ''; life_situation.split('\n').forEach(line => { const cleaned = line.replace(/^-\s+\*\*/, '').replace(/^-/, '').trim(); if (cleaned.startsWith('Arbeit/Studium:')) { job = cleaned.replace('Arbeit/Studium:', '').trim(); } else if (cleaned.startsWith('Hobbys/Interessen:')) { hobbies = cleaned.replace('Hobbys/Interessen:', '').trim(); } }); const milestonesText = sections['persönliche meilensteine'] || ''; const milestones = milestonesText.split('\n') .map(line => line.replace(/^-/, '').trim()) .filter(line => line.length > 0) .join('\n'); const randomText = sections['random infos'] || ''; let food_preferences = ''; randomText.split('\n').forEach(line => { const cleaned = line.replace(/^-\s+\*\*/, '').replace(/^-/, '').trim(); if (cleaned.startsWith('Lieblingsessen/-getränk:')) { food_preferences = cleaned.replace('Lieblingsessen/-getränk:', '').trim(); } }); const random_notes = sections['random infos'] || ''; // Insert parsed friend into database db.run(` INSERT INTO friends ( name, birthday, contact, address, relationship_status, family, honor, life_situation, job, hobbies, milestones, food_preferences, random_notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ name, birthday, contact, address, relationship_status, family, honor, life_situation, job, hobbies, milestones, food_preferences, random_notes ], function(err) { if (err) { return res.status(500).json({ error: err.message }); } const newFriendId = this.lastID; // Parse meetings and insert if any exist // In the Aaron Lingel.md file, meetings are dataview queries, but let's see if we can parse some meetings // Usually manual notes have a section like ## Treffen/Unterhaltungen const meetingsText = sections['treffen/unterhaltungen'] || ''; if (meetingsText && !meetingsText.includes('Letztes Treffen am: ')) { // Simple manual meeting parse if it has content let date = new Date().toISOString().split('T')[0]; let activity = 'Treffen'; let details = meetingsText; db.run(` INSERT INTO meetings (friend_id, date, activity, mood, details) VALUES (?, ?, ?, ?, ?) `, [newFriendId, date, activity, 'Zufrieden', details]); } res.status(201).json({ id: newFriendId, name, message: 'Obsidian file successfully imported!' }); }); } catch (err) { res.status(500).json({ error: 'Failed to parse Obsidian file: ' + err.message }); } }); // Default static serving: fallback to index.html for SPA router app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // Start Server app.listen(PORT, () => { console.log(`MischCRM server is running on http://localhost:${PORT}`); });