feat: initial commit for MischCRM - full private custom personal relationship manager
This commit is contained in:
541
server.js
Normal file
541
server.js
Normal 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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user