Major updates: LinkedIn auto-posting, timezone fixes, and Docker improvements

Features:
- Add LinkedIn OAuth integration and auto-posting functionality
- Add scheduler service for automated post publishing
- Add metadata field to generated_posts for LinkedIn URLs
- Add privacy policy page for LinkedIn API compliance
- Add company management features and employee accounts
- Add license key system for company registrations

Fixes:
- Fix timezone issues (use UTC consistently across app)
- Fix datetime serialization errors in database operations
- Fix scheduling timezone conversion (local time to UTC)
- Fix import errors (get_database -> db)

Infrastructure:
- Update Docker setup to use port 8001 (avoid conflicts)
- Add SSL support with nginx-proxy and Let's Encrypt
- Add LinkedIn setup documentation
- Add migration scripts for schema updates

Services:
- Add linkedin_service.py for LinkedIn API integration
- Add scheduler_service.py for background job processing
- Add storage_service.py for Supabase Storage
- Add email_service.py improvements
- Add encryption utilities for token storage

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 11:30:20 +01:00
parent b50594dbfa
commit f14515e9cf
94 changed files with 21601 additions and 5111 deletions

View File

@@ -0,0 +1,316 @@
{% extends "base.html" %}
{% block title %}Einstellungen{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto">
<div class="mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Einstellungen</h1>
<p class="text-gray-400">Verwalte deine Profil- und Email-Einstellungen</p>
</div>
{% if error %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<!-- Email Settings -->
<div class="card-bg rounded-xl border p-6 mb-6">
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
Email-Benachrichtigungen
</h2>
<p class="text-sm text-gray-400 mb-6">
Konfiguriere die Email-Adressen für den Freigabe-Workflow. Wenn ein Post in "Bearbeitet" verschoben wird,
erhält die Kunden-Email einen Link zur Freigabe. Nach der Entscheidung wird die Creator-Email benachrichtigt.
</p>
<form id="emailSettingsForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Creator-Email
<span class="text-gray-500 font-normal">(erhält Benachrichtigungen über Entscheidungen)</span>
</label>
<input type="email"
name="creator_email"
id="creatorEmail"
value="{{ profile.creator_email or '' }}"
placeholder="creator@example.com"
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-brand-highlight">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Kunden-Email
<span class="text-gray-500 font-normal">(erhält Posts zur Freigabe)</span>
</label>
<input type="email"
name="customer_email"
id="customerEmail"
value="{{ profile.customer_email or '' }}"
placeholder="kunde@example.com"
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-brand-highlight">
</div>
<div class="pt-4">
<button type="submit"
id="saveEmailsBtn"
class="px-6 py-3 bg-brand-highlight hover:bg-brand-highlight/90 text-brand-bg-dark font-medium rounded-lg transition-colors flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
Speichern
</button>
</div>
</form>
</div>
<!-- LinkedIn Account Connection -->
<div class="card-bg rounded-xl border border-gray-700 p-6 mb-6">
<h2 class="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-[#0A66C2]" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
LinkedIn-Konto verbinden
</h2>
{% if linkedin_account %}
<!-- Connected State -->
<div class="bg-green-900/20 border border-green-600 rounded-lg p-4 mb-4">
<div class="flex items-start gap-4">
{% if linkedin_account.linkedin_picture %}
<img src="{{ linkedin_account.linkedin_picture }}"
alt="{{ linkedin_account.linkedin_name }}"
class="w-12 h-12 rounded-full border-2 border-green-500">
{% endif %}
<div class="flex-1">
<p class="text-white font-medium flex items-center gap-2">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
{{ linkedin_account.linkedin_name }}
</p>
<p class="text-gray-400 text-sm mt-1">
Verbunden seit {{ linkedin_account.created_at.strftime('%d.%m.%Y um %H:%M') }} Uhr
</p>
{% if linkedin_account.last_used_at %}
<p class="text-gray-500 text-xs mt-1">
Zuletzt verwendet: {{ linkedin_account.last_used_at.strftime('%d.%m.%Y um %H:%M') }} Uhr
</p>
{% endif %}
</div>
</div>
</div>
{% if linkedin_account.last_error %}
<div class="bg-yellow-900/20 border border-yellow-600 rounded-lg p-4 mb-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-yellow-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div>
<p class="text-yellow-200 font-medium">Verbindungsproblem</p>
<p class="text-yellow-200/80 text-sm mt-1">
Deine LinkedIn-Verbindung funktioniert möglicherweise nicht mehr. Bitte verbinde dein Konto erneut.
</p>
<p class="text-yellow-600 text-xs mt-2 font-mono">{{ linkedin_account.last_error }}</p>
</div>
</div>
</div>
{% else %}
<div class="bg-blue-900/20 border border-blue-600 rounded-lg p-4 mb-4">
<p class="text-blue-200 text-sm">
<strong>✨ Automatisches Posten aktiviert!</strong><br>
Geplante Posts werden automatisch auf deinem LinkedIn-Profil veröffentlicht.
</p>
</div>
{% endif %}
<button onclick="disconnectLinkedIn()"
class="px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors flex items-center gap-2">
<svg class="w-4 h-4" 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>
Verbindung trennen
</button>
{% else %}
<!-- Not Connected State -->
<p class="text-gray-400 mb-4">
Verbinde dein LinkedIn-Konto, um Posts automatisch zu veröffentlichen.
Wenn dein Konto verbunden ist, werden geplante Posts direkt auf dein LinkedIn-Profil gepostet.
</p>
<div class="bg-brand-bg-light rounded-lg p-4 mb-4 border border-brand-bg-light">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-brand-highlight flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="text-sm text-gray-400">
<p class="font-medium text-white mb-2">Vorteile:</p>
<ul class="space-y-1">
<li>• Automatische Veröffentlichung zur geplanten Zeit</li>
<li>• Keine manuelle Arbeit mehr nötig</li>
<li>• Posts mit Bildern werden unterstützt</li>
<li>• Du bleibst im Workflow informiert</li>
</ul>
</div>
</div>
</div>
<a href="/settings/linkedin/connect"
class="inline-flex items-center gap-2 px-6 py-3 bg-[#0A66C2] hover:bg-[#004182] text-white rounded-lg transition-colors">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
Mit LinkedIn verbinden
</a>
{% endif %}
</div>
<!-- Workflow Info -->
<div class="card-bg rounded-xl border p-6">
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
So funktioniert der Workflow
</h2>
<div class="space-y-4 text-sm text-gray-400">
<div class="flex gap-3">
<div class="w-8 h-8 rounded-full bg-yellow-600/20 text-yellow-400 flex items-center justify-center flex-shrink-0 font-semibold">1</div>
<div>
<p class="text-white font-medium">Vorschlag</p>
<p>Neue Posts werden hier erstellt und können bearbeitet werden.</p>
</div>
</div>
<div class="flex gap-3">
<div class="w-8 h-8 rounded-full bg-blue-600/20 text-blue-400 flex items-center justify-center flex-shrink-0 font-semibold">2</div>
<div>
<p class="text-white font-medium">Bearbeitet</p>
<p>Wenn ein Post hierher verschoben wird, erhält die Kunden-Email eine Freigabe-Anfrage.</p>
</div>
</div>
<div class="flex gap-3">
<div class="w-8 h-8 rounded-full bg-green-600/20 text-green-400 flex items-center justify-center flex-shrink-0 font-semibold">3</div>
<div>
<p class="text-white font-medium">Veröffentlicht</p>
<p>Nach Freigabe durch den Kunden landet der Post hier und ist bereit für LinkedIn.</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('emailSettingsForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('saveEmailsBtn');
const originalHTML = btn.innerHTML;
btn.innerHTML = '<div class="w-4 h-4 border-2 border-brand-bg-dark border-t-transparent rounded-full animate-spin"></div> Speichern...';
btn.disabled = true;
try {
const formData = new FormData();
formData.append('creator_email', document.getElementById('creatorEmail').value);
formData.append('customer_email', document.getElementById('customerEmail').value);
const response = await fetch('/api/settings/emails', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Fehler beim Speichern');
}
// Show success
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> Gespeichert!';
btn.classList.remove('bg-brand-highlight');
btn.classList.add('bg-green-600');
setTimeout(() => {
btn.innerHTML = originalHTML;
btn.classList.remove('bg-green-600');
btn.classList.add('bg-brand-highlight');
btn.disabled = false;
}, 2000);
} catch (error) {
console.error('Error saving settings:', error);
btn.innerHTML = 'Fehler!';
btn.classList.remove('bg-brand-highlight');
btn.classList.add('bg-red-600');
setTimeout(() => {
btn.innerHTML = originalHTML;
btn.classList.remove('bg-red-600');
btn.classList.add('bg-brand-highlight');
btn.disabled = false;
}, 2000);
}
});
// LinkedIn disconnect
async function disconnectLinkedIn() {
if (!confirm('LinkedIn-Verbindung wirklich trennen?\n\nPosts werden dann nicht mehr automatisch veröffentlicht und du erhältst wieder Email-Benachrichtigungen.')) {
return;
}
try {
const response = await fetch('/api/settings/linkedin/disconnect', {
method: 'POST'
});
if (response.ok) {
window.location.reload();
} else {
alert('Fehler beim Trennen der Verbindung. Bitte versuche es erneut.');
}
} catch (error) {
console.error('Error disconnecting LinkedIn:', error);
alert('Fehler: ' + error.message);
}
}
// Show success/error messages from URL params
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('success')) {
const successMsg = urlParams.get('success');
if (successMsg === 'linkedin_connected') {
// Show temporary success message
const successDiv = document.createElement('div');
successDiv.className = 'fixed top-4 right-4 bg-green-600 text-white px-6 py-3 rounded-lg shadow-lg z-50';
successDiv.textContent = '✓ LinkedIn-Konto erfolgreich verbunden!';
document.body.appendChild(successDiv);
setTimeout(() => successDiv.remove(), 3000);
// Clean URL
window.history.replaceState({}, '', window.location.pathname);
}
}
if (urlParams.has('error')) {
const errorMsg = urlParams.get('error');
const errorMessages = {
'linkedin_auth_failed': 'LinkedIn-Authentifizierung fehlgeschlagen',
'invalid_state': 'Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut.',
'token_exchange_failed': 'Token-Austausch fehlgeschlagen',
'userinfo_failed': 'Konnte LinkedIn-Profil nicht abrufen',
'connection_failed': 'Verbindung fehlgeschlagen. Bitte versuche es erneut.'
};
const errorDiv = document.createElement('div');
errorDiv.className = 'fixed top-4 right-4 bg-red-600 text-white px-6 py-3 rounded-lg shadow-lg z-50';
errorDiv.textContent = '✗ ' + (errorMessages[errorMsg] || 'Ein Fehler ist aufgetreten');
document.body.appendChild(errorDiv);
setTimeout(() => errorDiv.remove(), 5000);
// Clean URL
window.history.replaceState({}, '', window.location.pathname);
}
</script>
{% endblock %}