added manual post add

This commit is contained in:
2026-04-02 10:39:07 +02:00
parent 252edcd001
commit fe15a5ab89
5 changed files with 744 additions and 7 deletions

View File

@@ -84,8 +84,6 @@ class DatabaseClient:
async def save_linkedin_posts(self, posts: List[LinkedInPost]) -> List[LinkedInPost]: async def save_linkedin_posts(self, posts: List[LinkedInPost]) -> List[LinkedInPost]:
"""Save LinkedIn posts (bulk).""" """Save LinkedIn posts (bulk)."""
from datetime import datetime
seen = set() seen = set()
unique_posts = [] unique_posts = []
for p in posts: for p in posts:
@@ -99,11 +97,9 @@ class DatabaseClient:
data = [] data = []
for p in unique_posts: for p in unique_posts:
post_dict = p.model_dump(exclude={"id", "scraped_at"}, exclude_none=True) # Use JSON mode so UUIDs/datetimes are serialized before the Supabase client
if "user_id" in post_dict: # builds its request payload.
post_dict["user_id"] = str(post_dict["user_id"]) post_dict = p.model_dump(mode="json", exclude={"id", "scraped_at"}, exclude_none=True)
if "post_date" in post_dict and isinstance(post_dict["post_date"], datetime):
post_dict["post_date"] = post_dict["post_date"].isoformat()
data.append(post_dict) data.append(post_dict)
if not data: if not data:
@@ -176,6 +172,15 @@ class DatabaseClient:
await cache.invalidate_linkedin_posts(user_id) await cache.invalidate_linkedin_posts(user_id)
logger.info(f"Deleted LinkedIn post: {post_id}") logger.info(f"Deleted LinkedIn post: {post_id}")
async def get_linkedin_post(self, post_id: UUID) -> Optional[LinkedInPost]:
"""Get a single LinkedIn post by ID."""
result = await asyncio.to_thread(
lambda: self.client.table("linkedin_posts").select("*").eq("id", str(post_id)).limit(1).execute()
)
if not result.data:
return None
return LinkedInPost(**result.data[0])
async def get_unclassified_posts(self, user_id: UUID) -> List[LinkedInPost]: async def get_unclassified_posts(self, user_id: UUID) -> List[LinkedInPost]:
"""Get all LinkedIn posts without a post_type_id.""" """Get all LinkedIn posts without a post_type_id."""
result = await asyncio.to_thread( result = await asyncio.to_thread(

View File

@@ -187,6 +187,40 @@ function showToast(message, type = 'info') {
</div> </div>
{% endif %} {% endif %}
<!-- Posts Modal -->
<div id="posts-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50">
<div class="bg-brand-bg-dark border border-gray-600 rounded-xl p-8 w-full max-w-4xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-white" id="posts-modal-title">Posts verwalten</h2>
<button onclick="closePostsModal()" class="text-gray-400 hover:text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-semibold text-white mb-3">Vorhandene Posts</h3>
<div id="posts-modal-list" class="space-y-3"></div>
</div>
<div>
<h3 class="text-lg font-semibold text-white mb-3">Post manuell hinzufügen</h3>
<form onsubmit="addManualPostToType(event)" class="space-y-4">
<input type="hidden" id="manual-post-type-id" value="">
<textarea id="manual-post-text" rows="10"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Füge hier einen bestehenden Beispielpost ein. Er wird direkt diesem Post-Typ zugeordnet."></textarea>
<p class="text-sm text-gray-400">Dieser Post wird gespeichert und bei einer manuell gestarteten Re-Analyse mit berücksichtigt.</p>
<div class="flex justify-end gap-3">
<button type="button" onclick="closePostsModal()" class="px-5 py-2.5 bg-gray-600 hover:bg-gray-500 text-white rounded-lg transition-colors">Schließen</button>
<button type="submit" class="btn-primary px-5 py-2.5 rounded-lg font-medium">Post hinzufügen</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal (Wizard-Style) --> <!-- Create/Edit Modal (Wizard-Style) -->
<div id="edit-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50"> <div id="edit-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50">
<div class="bg-brand-bg-dark border border-gray-600 rounded-xl p-8 w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto"> <div class="bg-brand-bg-dark border border-gray-600 rounded-xl p-8 w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto">
@@ -422,6 +456,11 @@ function renderPostTypesList() {
${pt.description ? `<p class="text-gray-400 text-sm">${escapeHtml(pt.description)}</p>` : ''} ${pt.description ? `<p class="text-gray-400 text-sm">${escapeHtml(pt.description)}</p>` : ''}
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button onclick="openPostsModal('${pt.id}')"
${String(pt.id).startsWith('temp_') ? 'disabled' : ''}
class="px-3 py-2 text-sm text-brand-highlight hover:text-yellow-300 hover:bg-brand-highlight/10 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
Posts
</button>
<button onclick="editPostTypeState('${pt.id}')" <button onclick="editPostTypeState('${pt.id}')"
class="px-3 py-2 text-sm text-blue-400 hover:text-blue-300 hover:bg-blue-900/20 rounded-lg transition-colors"> class="px-3 py-2 text-sm text-blue-400 hover:text-blue-300 hover:bg-blue-900/20 rounded-lg transition-colors">
Bearbeiten Bearbeiten
@@ -553,6 +592,120 @@ function updateStrategyWeightState(id, value) {
checkForChanges(); checkForChanges();
} }
let currentPostsModalPostTypeId = null;
let currentPostsModalPostTypeName = '';
async function openPostsModal(postTypeId) {
if (String(postTypeId).startsWith('temp_')) {
showToast('Bitte zuerst speichern, bevor du manuelle Posts verwaltest.', 'warning');
return;
}
const postType = postTypesState.find(pt => pt.id === postTypeId);
currentPostsModalPostTypeId = postTypeId;
currentPostsModalPostTypeName = postType ? postType.name : 'Post-Typ';
document.getElementById('posts-modal-title').textContent = `Posts in ${currentPostsModalPostTypeName}`;
document.getElementById('manual-post-type-id').value = postTypeId;
document.getElementById('manual-post-text').value = '';
document.getElementById('posts-modal').classList.remove('hidden');
document.getElementById('posts-modal').classList.add('flex');
await loadPostsForModal(postTypeId);
}
function closePostsModal() {
document.getElementById('posts-modal').classList.add('hidden');
document.getElementById('posts-modal').classList.remove('flex');
currentPostsModalPostTypeId = null;
currentPostsModalPostTypeName = '';
}
async function loadPostsForModal(postTypeId) {
const container = document.getElementById('posts-modal-list');
container.innerHTML = '<p class="text-gray-400">Lade Posts...</p>';
try {
const response = await fetch(`/api/company/manage/post-types/${postTypeId}/posts?employee_id=${EMPLOYEE_ID}`);
const result = await response.json();
if (!result.success) throw new Error(result.error || 'Posts konnten nicht geladen werden');
const posts = result.posts || [];
if (!posts.length) {
container.innerHTML = '<p class="text-gray-400">Noch keine Posts in diesem Post-Typ.</p>';
return;
}
container.innerHTML = posts.map(post => `
<div class="border border-brand-bg-light rounded-lg p-4 bg-brand-bg/30">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="text-xs text-gray-400">
${post.post_date ? new Date(post.post_date).toLocaleString('de-DE') : 'Kein Datum'}
${post.classification_method ? ` · ${escapeHtml(post.classification_method)}` : ''}
</div>
<button onclick="deletePostFromModal('${post.id}')"
class="px-3 py-1.5 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded-lg transition-colors">
Löschen
</button>
</div>
<div class="text-sm text-gray-200 whitespace-pre-wrap">${escapeHtml(post.post_text)}</div>
</div>
`).join('');
} catch (error) {
container.innerHTML = `<p class="text-red-400">Fehler: ${escapeHtml(error.message)}</p>`;
}
}
async function addManualPostToType(event) {
event.preventDefault();
const postTypeId = document.getElementById('manual-post-type-id').value;
const postText = document.getElementById('manual-post-text').value.trim();
if (postText.length < 20) {
showToast('Bitte einen aussagekräftigen Post mit mindestens 20 Zeichen eingeben.', 'error');
return;
}
try {
const response = await fetch('/api/company/manage/post-types/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ employee_id: EMPLOYEE_ID, post_type_id: postTypeId, post_text: postText })
});
const result = await response.json();
if (!result.success) throw new Error(result.error || 'Post konnte nicht hinzugefügt werden');
const postType = postTypesState.find(pt => pt.id === postTypeId);
if (postType) postType.post_count = (postType.post_count || 0) + 1;
renderPostTypesList();
document.getElementById('manual-post-text').value = '';
await loadPostsForModal(postTypeId);
showToast('Post hinzugefügt. Er wird bei der nächsten manuellen Re-Analyse mit berücksichtigt.', 'success');
} catch (error) {
showToast(`Fehler beim Hinzufügen: ${error.message}`, 'error');
}
}
async function deletePostFromModal(postId) {
if (!currentPostsModalPostTypeId) return;
try {
const response = await fetch(`/api/company/manage/post-types/${currentPostsModalPostTypeId}/posts/${postId}?employee_id=${EMPLOYEE_ID}`, {
method: 'DELETE'
});
const result = await response.json();
if (!result.success) throw new Error(result.error || 'Post konnte nicht gelöscht werden');
const postType = postTypesState.find(pt => pt.id === currentPostsModalPostTypeId);
if (postType && postType.post_count > 0) postType.post_count -= 1;
renderPostTypesList();
await loadPostsForModal(currentPostsModalPostTypeId);
showToast('Post gelöscht.', 'success');
} catch (error) {
showToast(`Fehler beim Löschen: ${error.message}`, 'error');
}
}
async function saveAllChanges() { async function saveAllChanges() {
const saveBtn = document.getElementById('save-all-btn'); const saveBtn = document.getElementById('save-all-btn');
if (!saveBtn) return; if (!saveBtn) return;

View File

@@ -224,6 +224,40 @@ function showToast(message, type = 'info') {
{% endif %} {% endif %}
</div> </div>
<!-- Posts Modal -->
<div id="posts-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50">
<div class="bg-brand-bg-dark border border-gray-600 rounded-xl p-8 w-full max-w-4xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-white" id="posts-modal-title">Posts verwalten</h2>
<button onclick="closePostsModal()" class="text-gray-400 hover:text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-semibold text-white mb-3">Vorhandene Posts</h3>
<div id="posts-modal-list" class="space-y-3"></div>
</div>
<div>
<h3 class="text-lg font-semibold text-white mb-3">Post manuell hinzufügen</h3>
<form onsubmit="addManualPostToType(event)" class="space-y-4">
<input type="hidden" id="manual-post-type-id" value="">
<textarea id="manual-post-text" rows="10"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Füge hier einen bestehenden Beispielpost ein. Er wird direkt diesem Post-Typ zugeordnet."></textarea>
<p class="text-sm text-gray-400">Dieser Post wird gespeichert und bei einer manuell gestarteten Re-Analyse mit berücksichtigt.</p>
<div class="flex justify-end gap-3">
<button type="button" onclick="closePostsModal()" class="px-5 py-2.5 bg-gray-600 hover:bg-gray-500 text-white rounded-lg transition-colors">Schließen</button>
<button type="submit" class="btn-primary px-5 py-2.5 rounded-lg font-medium">Post hinzufügen</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal (Wizard-Style) --> <!-- Create/Edit Modal (Wizard-Style) -->
<div id="edit-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50"> <div id="edit-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50">
<div class="bg-brand-bg-dark border border-gray-600 rounded-xl p-8 w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto"> <div class="bg-brand-bg-dark border border-gray-600 rounded-xl p-8 w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto">
@@ -568,6 +602,15 @@ function renderPostTypesList() {
${pt.description ? `<p class="text-gray-400 text-sm">${escapeHtml(pt.description)}</p>` : ''} ${pt.description ? `<p class="text-gray-400 text-sm">${escapeHtml(pt.description)}</p>` : ''}
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button onclick="openPostsModal('${pt.id}')"
${String(pt.id).startsWith('temp_') ? 'disabled' : ''}
class="px-3 py-2 text-sm text-brand-highlight hover:text-yellow-300 hover:bg-brand-highlight/10 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
Posts
</button>
<button onclick="editPostTypeState('${pt.id}')"
class="px-3 py-2 text-sm text-blue-400 hover:text-blue-300 hover:bg-blue-900/20 rounded-lg transition-colors">
Bearbeiten
</button>
<button onclick="deletePostTypeState('${pt.id}')" <button onclick="deletePostTypeState('${pt.id}')"
class="px-3 py-2 text-sm text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded-lg transition-colors"> class="px-3 py-2 text-sm text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded-lg transition-colors">
Löschen Löschen
@@ -748,6 +791,120 @@ function updateStrategyWeightState(id, value) {
checkForChanges(); checkForChanges();
} }
let currentPostsModalPostTypeId = null;
let currentPostsModalPostTypeName = '';
async function openPostsModal(postTypeId) {
if (String(postTypeId).startsWith('temp_')) {
showToast('Bitte zuerst speichern, bevor du manuelle Posts verwaltest.', 'warning');
return;
}
const postType = postTypesState.find(pt => pt.id === postTypeId);
currentPostsModalPostTypeId = postTypeId;
currentPostsModalPostTypeName = postType ? postType.name : 'Post-Typ';
document.getElementById('posts-modal-title').textContent = `Posts in ${currentPostsModalPostTypeName}`;
document.getElementById('manual-post-type-id').value = postTypeId;
document.getElementById('manual-post-text').value = '';
document.getElementById('posts-modal').classList.remove('hidden');
document.getElementById('posts-modal').classList.add('flex');
await loadPostsForModal(postTypeId);
}
function closePostsModal() {
document.getElementById('posts-modal').classList.add('hidden');
document.getElementById('posts-modal').classList.remove('flex');
currentPostsModalPostTypeId = null;
currentPostsModalPostTypeName = '';
}
async function loadPostsForModal(postTypeId) {
const container = document.getElementById('posts-modal-list');
container.innerHTML = '<p class="text-gray-400">Lade Posts...</p>';
try {
const response = await fetch(`/api/employee/post-types/${postTypeId}/posts`);
const result = await response.json();
if (!result.success) throw new Error(result.error || 'Posts konnten nicht geladen werden');
const posts = result.posts || [];
if (!posts.length) {
container.innerHTML = '<p class="text-gray-400">Noch keine Posts in diesem Post-Typ.</p>';
return;
}
container.innerHTML = posts.map(post => `
<div class="border border-brand-bg-light rounded-lg p-4 bg-brand-bg/30">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="text-xs text-gray-400">
${post.post_date ? new Date(post.post_date).toLocaleString('de-DE') : 'Kein Datum'}
${post.classification_method ? ` · ${escapeHtml(post.classification_method)}` : ''}
</div>
<button onclick="deletePostFromModal('${post.id}')"
class="px-3 py-1.5 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded-lg transition-colors">
Löschen
</button>
</div>
<div class="text-sm text-gray-200 whitespace-pre-wrap">${escapeHtml(post.post_text)}</div>
</div>
`).join('');
} catch (error) {
container.innerHTML = `<p class="text-red-400">Fehler: ${escapeHtml(error.message)}</p>`;
}
}
async function addManualPostToType(event) {
event.preventDefault();
const postTypeId = document.getElementById('manual-post-type-id').value;
const postText = document.getElementById('manual-post-text').value.trim();
if (postText.length < 20) {
showToast('Bitte einen aussagekräftigen Post mit mindestens 20 Zeichen eingeben.', 'error');
return;
}
try {
const response = await fetch('/api/employee/post-types/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ post_type_id: postTypeId, post_text: postText })
});
const result = await response.json();
if (!result.success) throw new Error(result.error || 'Post konnte nicht hinzugefügt werden');
const postType = postTypesState.find(pt => pt.id === postTypeId);
if (postType) postType.post_count = (postType.post_count || 0) + 1;
renderPostTypesList();
document.getElementById('manual-post-text').value = '';
await loadPostsForModal(postTypeId);
showToast('Post hinzugefügt. Er wird bei der nächsten manuellen Re-Analyse mit berücksichtigt.', 'success');
} catch (error) {
showToast(`Fehler beim Hinzufügen: ${error.message}`, 'error');
}
}
async function deletePostFromModal(postId) {
if (!currentPostsModalPostTypeId) return;
try {
const response = await fetch(`/api/employee/post-types/${currentPostsModalPostTypeId}/posts/${postId}`, {
method: 'DELETE'
});
const result = await response.json();
if (!result.success) throw new Error(result.error || 'Post konnte nicht gelöscht werden');
const postType = postTypesState.find(pt => pt.id === currentPostsModalPostTypeId);
if (postType && postType.post_count > 0) postType.post_count -= 1;
renderPostTypesList();
await loadPostsForModal(currentPostsModalPostTypeId);
showToast('Post gelöscht.', 'success');
} catch (error) {
showToast(`Fehler beim Löschen: ${error.message}`, 'error');
}
}
// Save all changes to database // Save all changes to database
async function saveAllChanges() { async function saveAllChanges() {
const saveBtn = document.getElementById('save-all-btn'); const saveBtn = document.getElementById('save-all-btn');

View File

@@ -22,6 +22,12 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Analyse starten Analyse starten
</button> </button>
<button onclick="openManualPostModal()" class="px-5 py-2.5 rounded-lg font-medium flex items-center gap-2 bg-brand-highlight text-brand-bg-dark hover:bg-brand-highlight-dark transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Post manuell hinzufügen
</button>
</div> </div>
</div> </div>
@@ -100,6 +106,10 @@
<option value="{{ pt.id }}">{{ pt.name }}</option> <option value="{{ pt.id }}">{{ pt.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
<button onclick="deletePost('{{ post.id }}')"
class="mt-2 w-full px-3 py-2 text-sm text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded-lg transition-colors">
Post löschen
</button>
</div> </div>
</div> </div>
@@ -160,6 +170,10 @@
<option value="{{ pt.id }}" {% if pt.id == post.post_type_id %}selected{% endif %}>{{ pt.name }}</option> <option value="{{ pt.id }}" {% if pt.id == post.post_type_id %}selected{% endif %}>{{ pt.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
<button onclick="deletePost('{{ post.id }}')"
class="mt-2 w-full px-3 py-2 text-sm text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded-lg transition-colors">
Post löschen
</button>
</div> </div>
</div> </div>
@@ -207,7 +221,99 @@
</div> </div>
</div> </div>
<!-- Manual Post Modal -->
<div id="manual-post-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50">
<div class="bg-brand-bg-dark border border-gray-600 rounded-xl p-8 w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-white">Post manuell hinzufügen</h2>
<button onclick="closeManualPostModal()" class="text-gray-400 hover:text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form onsubmit="submitManualPost(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ</label>
<select id="manual-post-type-id" class="w-full input-bg border rounded-lg px-4 py-3 text-white">
<option value="">Nicht kategorisiert</option>
{% for pt in post_types %}
<option value="{{ pt.id }}">{{ pt.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Text</label>
<textarea id="manual-post-text" rows="10"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Füge hier einen bestehenden Beispielpost ein. Er wird gespeichert und bei der nächsten manuellen Re-Analyse berücksichtigt."></textarea>
</div>
<div class="flex justify-end gap-3">
<button type="button" onclick="closeManualPostModal()" class="px-5 py-2.5 bg-gray-600 hover:bg-gray-500 text-white rounded-lg transition-colors">Abbrechen</button>
<button type="submit" class="btn-primary px-5 py-2.5 rounded-lg font-medium">Post speichern</button>
</div>
</form>
</div>
</div>
<script> <script>
function openManualPostModal() {
document.getElementById('manual-post-text').value = '';
document.getElementById('manual-post-type-id').value = '';
document.getElementById('manual-post-modal').classList.remove('hidden');
document.getElementById('manual-post-modal').classList.add('flex');
}
function closeManualPostModal() {
document.getElementById('manual-post-modal').classList.add('hidden');
document.getElementById('manual-post-modal').classList.remove('flex');
}
async function submitManualPost(event) {
event.preventDefault();
const postText = document.getElementById('manual-post-text').value.trim();
const postTypeId = document.getElementById('manual-post-type-id').value;
if (postText.length < 20) {
if (window.showToast) showToast('Bitte einen Post mit mindestens 20 Zeichen eingeben.', 'error');
return;
}
try {
const response = await fetch('/api/post-types/manual-posts', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ post_text: postText, post_type_id: postTypeId || null })
});
const result = await response.json();
if (!result.success) throw new Error(result.error || 'Post konnte nicht hinzugefügt werden');
closeManualPostModal();
if (window.showToast) {
showToast('Post hinzugefügt. Er wird bei der nächsten manuellen Re-Analyse berücksichtigt.', 'success');
}
window.location.reload();
} catch (error) {
if (window.showToast) showToast(`Fehler: ${error.message}`, 'error');
}
}
async function deletePost(postId) {
if (!confirm('Diesen Post wirklich löschen?')) return;
try {
const response = await fetch(`/api/post-types/posts/${postId}`, { method: 'DELETE' });
const result = await response.json();
if (!result.success) throw new Error(result.error || 'Post konnte nicht gelöscht werden');
const postEl = document.querySelector(`[data-post-id="${postId}"]`);
if (postEl) postEl.remove();
if (window.showToast) showToast('Post gelöscht.', 'success');
} catch (error) {
if (window.showToast) showToast(`Fehler: ${error.message}`, 'error');
}
}
function toggleExpand(btn) { function toggleExpand(btn) {
const card = btn.closest('.post-item'); const card = btn.closest('.post-item');
const fullContent = card.querySelector('.full-content'); const fullContent = card.querySelector('.full-content');

View File

@@ -1581,6 +1581,78 @@ async def api_categorize_post(request: Request):
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
@user_router.post("/api/post-types/manual-posts")
async def api_add_manual_post_for_post_types(request: Request):
"""Add a manual post from the post types page and optionally assign a type immediately."""
session = require_user_session(request)
if not session:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
try:
data = await request.json()
user_id = UUID(session.user_id)
post_text = (data.get("post_text") or "").strip()
post_type_id_raw = data.get("post_type_id")
if len(post_text) < 20:
return JSONResponse({"error": "Post muss mindestens 20 Zeichen lang sein"}, status_code=400)
post_type_id = UUID(post_type_id_raw) if post_type_id_raw else None
if post_type_id:
post_type = await db.get_post_type(post_type_id)
if not post_type or post_type.user_id != user_id:
return JSONResponse({"error": "Post-Typ nicht gefunden oder kein Zugriff"}, status_code=404)
from uuid import uuid4
from src.database.models import LinkedInPost
manual_post = LinkedInPost(
user_id=user_id,
post_text=post_text,
post_url=f"manual://{uuid4()}",
post_date=datetime.now(timezone.utc),
post_type_id=post_type_id,
classification_method="manual",
classification_confidence=1.0,
raw_data={"source": "manual", "manual_entry": True}
)
saved_posts = await db.save_linkedin_posts([manual_post])
saved = saved_posts[0] if saved_posts else None
return JSONResponse({
"success": True,
"post": {
"id": str(saved.id) if saved and saved.id else None,
"post_text": saved.post_text if saved else post_text,
"post_date": saved.post_date.isoformat() if saved and saved.post_date else None,
"post_type_id": str(saved.post_type_id) if saved and saved.post_type_id else None,
}
})
except Exception as e:
logger.error(f"Error adding manual post from post types page: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@user_router.delete("/api/post-types/posts/{post_id}")
async def api_delete_post_from_post_types_page(request: Request, post_id: str):
"""Delete a post from the main post types page."""
session = require_user_session(request)
if not session:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
try:
user_id = UUID(session.user_id)
post = await db.get_linkedin_post(UUID(post_id))
if not post or post.user_id != user_id:
return JSONResponse({"error": "Post nicht gefunden oder kein Zugriff"}, status_code=404)
await db.delete_linkedin_post(UUID(post_id))
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"Error deleting post from post types page: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@user_router.get("/api/job-updates") @user_router.get("/api/job-updates")
async def job_updates_sse(request: Request): async def job_updates_sse(request: Request):
"""Server-Sent Events endpoint for job updates (Redis pub/sub — works across workers).""" """Server-Sent Events endpoint for job updates (Redis pub/sub — works across workers)."""
@@ -4259,6 +4331,250 @@ async def company_save_all_employee_post_types(request: Request, background_task
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
@user_router.get("/api/employee/post-types/{post_type_id}/posts")
async def employee_list_post_type_posts(request: Request, post_type_id: str):
"""List posts assigned to a post type."""
session = require_user_session(request)
if not session:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
if session.account_type != "employee":
return JSONResponse({"error": "Only employees can access this endpoint"}, status_code=403)
try:
user_id = UUID(session.user_id)
pt_id = UUID(post_type_id)
post_type = await db.get_post_type(pt_id)
if not post_type or post_type.user_id != user_id:
return JSONResponse({"error": "Post type not found or access denied"}, status_code=404)
posts = await db.get_posts_by_type(user_id, pt_id)
return JSONResponse({
"success": True,
"posts": [
{
"id": str(p.id),
"post_text": p.post_text,
"post_date": p.post_date.isoformat() if p.post_date else None,
"post_url": p.post_url,
"classification_method": p.classification_method,
}
for p in posts
]
})
except Exception as e:
logger.error(f"Error listing employee post type posts: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@user_router.post("/api/employee/post-types/posts")
async def employee_add_manual_post_to_type(request: Request):
"""Add a manual post and assign it to a post type."""
session = require_user_session(request)
if not session:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
if session.account_type != "employee":
return JSONResponse({"error": "Only employees can add posts here"}, status_code=403)
try:
data = await request.json()
user_id = UUID(session.user_id)
pt_id = UUID(data.get("post_type_id"))
post_type = await db.get_post_type(pt_id)
if not post_type or post_type.user_id != user_id:
return JSONResponse({"error": "Post type not found or access denied"}, status_code=404)
post_text = (data.get("post_text") or "").strip()
if len(post_text) < 20:
return JSONResponse({"error": "Post muss mindestens 20 Zeichen lang sein"}, status_code=400)
from uuid import uuid4
from src.database.models import LinkedInPost
manual_post = LinkedInPost(
user_id=user_id,
post_text=post_text,
post_url=f"manual://{uuid4()}",
post_date=datetime.now(timezone.utc),
post_type_id=pt_id,
classification_method="manual",
classification_confidence=1.0,
raw_data={"source": "manual", "manual_entry": True}
)
saved_posts = await db.save_linkedin_posts([manual_post])
saved = saved_posts[0] if saved_posts else None
return JSONResponse({
"success": True,
"post": {
"id": str(saved.id) if saved and saved.id else None,
"post_text": saved.post_text if saved else post_text,
"post_date": saved.post_date.isoformat() if saved and saved.post_date else None,
}
})
except Exception as e:
logger.error(f"Error adding manual post for employee post type: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@user_router.delete("/api/employee/post-types/{post_type_id}/posts/{post_id}")
async def employee_delete_post_from_type(request: Request, post_type_id: str, post_id: str):
"""Delete a post assigned to a post type."""
session = require_user_session(request)
if not session:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
if session.account_type != "employee":
return JSONResponse({"error": "Only employees can delete posts here"}, status_code=403)
try:
user_id = UUID(session.user_id)
pt_id = UUID(post_type_id)
post_type = await db.get_post_type(pt_id)
if not post_type or post_type.user_id != user_id:
return JSONResponse({"error": "Post type not found or access denied"}, status_code=404)
post = await db.get_linkedin_post(UUID(post_id))
if not post or post.user_id != user_id or post.post_type_id != pt_id:
return JSONResponse({"error": "Post not found or access denied"}, status_code=404)
await db.delete_linkedin_post(UUID(post_id))
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"Error deleting employee post from type: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@user_router.get("/api/company/manage/post-types/{post_type_id}/posts")
async def company_list_employee_post_type_posts(request: Request, post_type_id: str, employee_id: str):
"""Company lists posts assigned to an employee's post type."""
session = require_user_session(request)
if not session:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
if session.account_type != "company" or not session.company_id:
return JSONResponse({"error": "Only company accounts can access this endpoint"}, status_code=403)
try:
emp_id = UUID(employee_id)
emp_user = await db.get_user(emp_id)
if not emp_user or str(emp_user.company_id) != session.company_id:
return JSONResponse({"error": "Employee not found or not in company"}, status_code=403)
pt_id = UUID(post_type_id)
post_type = await db.get_post_type(pt_id)
if not post_type or post_type.user_id != emp_id:
return JSONResponse({"error": "Post type not found or access denied"}, status_code=404)
posts = await db.get_posts_by_type(emp_id, pt_id)
return JSONResponse({
"success": True,
"posts": [
{
"id": str(p.id),
"post_text": p.post_text,
"post_date": p.post_date.isoformat() if p.post_date else None,
"post_url": p.post_url,
"classification_method": p.classification_method,
}
for p in posts
]
})
except Exception as e:
logger.error(f"Error listing company-managed employee post type posts: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@user_router.post("/api/company/manage/post-types/posts")
async def company_add_manual_post_to_employee_type(request: Request):
"""Company adds a manual post for an employee and assigns it to a post type."""
session = require_user_session(request)
if not session:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
if session.account_type != "company" or not session.company_id:
return JSONResponse({"error": "Only company accounts can add posts here"}, status_code=403)
try:
data = await request.json()
emp_id = UUID(data.get("employee_id"))
emp_user = await db.get_user(emp_id)
if not emp_user or str(emp_user.company_id) != session.company_id:
return JSONResponse({"error": "Employee not found or not in company"}, status_code=403)
perms = await get_employee_permissions_or_default(emp_id, UUID(session.company_id))
if not perms.get("can_manage_post_types", True):
return JSONResponse({"error": "Keine Berechtigung"}, status_code=403)
pt_id = UUID(data.get("post_type_id"))
post_type = await db.get_post_type(pt_id)
if not post_type or post_type.user_id != emp_id:
return JSONResponse({"error": "Post type not found or access denied"}, status_code=404)
post_text = (data.get("post_text") or "").strip()
if len(post_text) < 20:
return JSONResponse({"error": "Post muss mindestens 20 Zeichen lang sein"}, status_code=400)
from uuid import uuid4
from src.database.models import LinkedInPost
manual_post = LinkedInPost(
user_id=emp_id,
post_text=post_text,
post_url=f"manual://{uuid4()}",
post_date=datetime.now(timezone.utc),
post_type_id=pt_id,
classification_method="manual",
classification_confidence=1.0,
raw_data={"source": "manual", "manual_entry": True}
)
saved_posts = await db.save_linkedin_posts([manual_post])
saved = saved_posts[0] if saved_posts else None
return JSONResponse({
"success": True,
"post": {
"id": str(saved.id) if saved and saved.id else None,
"post_text": saved.post_text if saved else post_text,
"post_date": saved.post_date.isoformat() if saved and saved.post_date else None,
}
})
except Exception as e:
logger.error(f"Error adding manual post for company-managed employee post type: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@user_router.delete("/api/company/manage/post-types/{post_type_id}/posts/{post_id}")
async def company_delete_post_from_employee_type(request: Request, post_type_id: str, post_id: str, employee_id: str):
"""Company deletes a post assigned to an employee's post type."""
session = require_user_session(request)
if not session:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
if session.account_type != "company" or not session.company_id:
return JSONResponse({"error": "Only company accounts can delete posts here"}, status_code=403)
try:
emp_id = UUID(employee_id)
emp_user = await db.get_user(emp_id)
if not emp_user or str(emp_user.company_id) != session.company_id:
return JSONResponse({"error": "Employee not found or not in company"}, status_code=403)
perms = await get_employee_permissions_or_default(emp_id, UUID(session.company_id))
if not perms.get("can_manage_post_types", True):
return JSONResponse({"error": "Keine Berechtigung"}, status_code=403)
pt_id = UUID(post_type_id)
post_type = await db.get_post_type(pt_id)
if not post_type or post_type.user_id != emp_id:
return JSONResponse({"error": "Post type not found or access denied"}, status_code=404)
post = await db.get_linkedin_post(UUID(post_id))
if not post or post.user_id != emp_id or post.post_type_id != pt_id:
return JSONResponse({"error": "Post not found or access denied"}, status_code=404)
await db.delete_linkedin_post(UUID(post_id))
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"Error deleting company-managed employee post from type: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@user_router.post("/api/employee/post-types/save-all") @user_router.post("/api/employee/post-types/save-all")
async def save_all_and_reanalyze(request: Request, background_tasks: BackgroundTasks): async def save_all_and_reanalyze(request: Request, background_tasks: BackgroundTasks):
"""Save all changes and conditionally trigger re-categorization based on structural changes.""" """Save all changes and conditionally trigger re-categorization based on structural changes."""