feat: initial commit for MischCRM - full private custom personal relationship manager

This commit is contained in:
Kroonk
2026-05-22 15:51:06 +02:00
commit 16e59b1532
10 changed files with 6041 additions and 0 deletions

541
server.js Normal file
View File

@@ -0,0 +1,541 @@
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}`);
});