added manual post add
This commit is contained in:
@@ -84,8 +84,6 @@ class DatabaseClient:
|
||||
|
||||
async def save_linkedin_posts(self, posts: List[LinkedInPost]) -> List[LinkedInPost]:
|
||||
"""Save LinkedIn posts (bulk)."""
|
||||
from datetime import datetime
|
||||
|
||||
seen = set()
|
||||
unique_posts = []
|
||||
for p in posts:
|
||||
@@ -99,11 +97,9 @@ class DatabaseClient:
|
||||
|
||||
data = []
|
||||
for p in unique_posts:
|
||||
post_dict = p.model_dump(exclude={"id", "scraped_at"}, exclude_none=True)
|
||||
if "user_id" in post_dict:
|
||||
post_dict["user_id"] = str(post_dict["user_id"])
|
||||
if "post_date" in post_dict and isinstance(post_dict["post_date"], datetime):
|
||||
post_dict["post_date"] = post_dict["post_date"].isoformat()
|
||||
# Use JSON mode so UUIDs/datetimes are serialized before the Supabase client
|
||||
# builds its request payload.
|
||||
post_dict = p.model_dump(mode="json", exclude={"id", "scraped_at"}, exclude_none=True)
|
||||
data.append(post_dict)
|
||||
|
||||
if not data:
|
||||
@@ -176,6 +172,15 @@ class DatabaseClient:
|
||||
await cache.invalidate_linkedin_posts(user_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]:
|
||||
"""Get all LinkedIn posts without a post_type_id."""
|
||||
result = await asyncio.to_thread(
|
||||
|
||||
@@ -187,6 +187,40 @@ function showToast(message, type = 'info') {
|
||||
</div>
|
||||
{% 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) -->
|
||||
<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">
|
||||
@@ -422,6 +456,11 @@ function renderPostTypesList() {
|
||||
${pt.description ? `<p class="text-gray-400 text-sm">${escapeHtml(pt.description)}</p>` : ''}
|
||||
</div>
|
||||
<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
|
||||
@@ -553,6 +592,120 @@ function updateStrategyWeightState(id, value) {
|
||||
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() {
|
||||
const saveBtn = document.getElementById('save-all-btn');
|
||||
if (!saveBtn) return;
|
||||
|
||||
@@ -224,6 +224,40 @@ function showToast(message, type = 'info') {
|
||||
{% endif %}
|
||||
</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) -->
|
||||
<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">
|
||||
@@ -568,6 +602,15 @@ function renderPostTypesList() {
|
||||
${pt.description ? `<p class="text-gray-400 text-sm">${escapeHtml(pt.description)}</p>` : ''}
|
||||
</div>
|
||||
<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}')"
|
||||
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
|
||||
@@ -748,6 +791,120 @@ function updateStrategyWeightState(id, value) {
|
||||
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
|
||||
async function saveAllChanges() {
|
||||
const saveBtn = document.getElementById('save-all-btn');
|
||||
|
||||
@@ -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>
|
||||
Analyse starten
|
||||
</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>
|
||||
|
||||
@@ -100,6 +106,10 @@
|
||||
<option value="{{ pt.id }}">{{ pt.name }}</option>
|
||||
{% endfor %}
|
||||
</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>
|
||||
|
||||
@@ -160,6 +170,10 @@
|
||||
<option value="{{ pt.id }}" {% if pt.id == post.post_type_id %}selected{% endif %}>{{ pt.name }}</option>
|
||||
{% endfor %}
|
||||
</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>
|
||||
|
||||
@@ -207,7 +221,99 @@
|
||||
</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>
|
||||
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) {
|
||||
const card = btn.closest('.post-item');
|
||||
const fullContent = card.querySelector('.full-content');
|
||||
|
||||
@@ -1581,6 +1581,78 @@ async def api_categorize_post(request: Request):
|
||||
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")
|
||||
async def job_updates_sse(request: Request):
|
||||
"""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)
|
||||
|
||||
|
||||
@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")
|
||||
async def save_all_and_reanalyze(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Save all changes and conditionally trigger re-categorization based on structural changes."""
|
||||
|
||||
Reference in New Issue
Block a user