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

@@ -57,14 +57,20 @@
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full overflow-hidden bg-brand-highlight flex items-center justify-center">
{% if session.linkedin_picture %}
<img src="{{ session.linkedin_picture }}" alt="{{ session.linkedin_name }}" class="w-full h-full object-cover" referrerpolicy="no-referrer">
<img src="{{ session.linkedin_picture }}" alt="{{ session.display_name or session.linkedin_name }}" class="w-full h-full object-cover" referrerpolicy="no-referrer">
{% else %}
<span class="text-brand-bg-dark font-bold">{{ session.customer_name[0] | upper }}</span>
<span class="text-brand-bg-dark font-bold">{{ (session.display_name or session.linkedin_name or session.customer_name)[0] | upper }}</span>
{% endif %}
</div>
<div class="flex-1 min-w-0">
<p class="text-white font-medium text-sm truncate">{{ session.linkedin_name or session.customer_name }}</p>
<p class="text-gray-400 text-xs truncate">{{ session.customer_name }}</p>
<p class="text-white font-medium text-sm truncate">{{ session.display_name or session.linkedin_name or 'Benutzer' }}</p>
{% if session.account_type == 'ghostwriter' and session.customer_name %}
<p class="text-gray-400 text-xs truncate">schreibt für: {{ session.customer_name }}</p>
{% elif session.account_type == 'employee' and session.company_name %}
<p class="text-gray-400 text-xs truncate">Mitarbeiter bei: {{ session.company_name }}</p>
{% else %}
<p class="text-gray-400 text-xs truncate">{{ session.email or '' }}</p>
{% endif %}
</div>
</div>
</div>
@@ -87,13 +93,35 @@
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
Meine Posts
</a>
<a href="/post-types" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'post_types' %}active{% endif %}">
<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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
Post-Typen
</a>
<a href="/status" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'status' %}active{% endif %}">
<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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
Status
</a>
{% if session and session.account_type == 'company' %}
<a href="/company/accounts" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'accounts' %}active{% endif %}">
<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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
Konten
</a>
{% endif %}
{% if session and session.account_type == 'employee' %}
<a href="/employee/strategy" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'strategy' %}active{% endif %}">
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>
Unternehmensstrategie
</a>
{% endif %}
</nav>
<div class="p-4 border-t border-gray-600">
<div class="p-4 border-t border-gray-600 space-y-2">
<a href="/settings" class="flex items-center gap-2 text-gray-400 hover:text-gray-200 text-sm transition-colors {% if page == 'settings' %}text-brand-highlight{% endif %}">
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Einstellungen
</a>
<a href="/logout" class="flex items-center gap-2 text-gray-400 hover:text-gray-200 text-sm transition-colors">
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
Logout
@@ -108,6 +136,122 @@
</div>
</main>
<!-- Toast Container -->
<div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
<!-- Background Jobs Script -->
<script>
(function() {
let eventSource = null;
function connectToJobUpdates() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/api/job-updates');
eventSource.onmessage = function(event) {
const job = JSON.parse(event.data);
showJobToast(job);
};
eventSource.onerror = function() {
// Reconnect after 5 seconds
setTimeout(connectToJobUpdates, 5000);
};
}
function showJobToast(job) {
const container = document.getElementById('toast-container');
let toast = document.getElementById('toast-' + job.id);
if (!toast) {
toast = document.createElement('div');
toast.id = 'toast-' + job.id;
toast.className = 'bg-brand-bg-dark border border-gray-600 rounded-lg shadow-lg p-4 min-w-80 transform transition-all duration-300';
container.appendChild(toast);
}
const statusColors = {
'pending': 'text-gray-400',
'running': 'text-brand-highlight',
'completed': 'text-green-400',
'failed': 'text-red-400'
};
const statusIcons = {
'pending': '<svg class="w-5 h-5 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
'running': '<svg class="w-5 h-5 animate-spin" 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>',
'completed': '<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
'failed': '<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 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
};
const jobTypeNames = {
'profile_analysis': 'Profil-Analyse',
'post_categorization': 'Kategorisierung',
'post_type_analysis': 'Post-Typen-Analyse'
};
toast.innerHTML = `
<div class="flex items-start gap-3">
<span class="${statusColors[job.status]}">${statusIcons[job.status]}</span>
<div class="flex-1">
<p class="font-medium text-white text-sm">${jobTypeNames[job.job_type] || job.job_type}</p>
<p class="text-gray-400 text-xs mt-1">${job.message || ''}</p>
${job.status === 'running' ? `
<div class="mt-2 bg-gray-700 rounded-full h-1.5 overflow-hidden">
<div class="bg-brand-highlight h-full transition-all duration-300" style="width: ${job.progress}%"></div>
</div>
` : ''}
${job.error ? `<p class="text-red-400 text-xs mt-1">${job.error}</p>` : ''}
</div>
${job.status === 'completed' || job.status === 'failed' ? `
<button onclick="this.parentElement.parentElement.remove()" class="text-gray-500 hover:text-gray-300">
<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>
</button>
` : ''}
</div>
`;
// Auto-remove completed toasts after 10 seconds
if (job.status === 'completed') {
setTimeout(() => {
if (toast.parentElement) {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}
}, 10000);
}
}
// Connect when page loads
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', connectToJobUpdates);
} else {
connectToJobUpdates();
}
// Expose for manual toasts
window.showToast = function(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
const colors = {
'info': 'bg-brand-highlight text-brand-bg-dark',
'success': 'bg-green-500 text-white',
'error': 'bg-red-500 text-white'
};
toast.className = `${colors[type]} px-6 py-3 rounded-lg shadow-lg`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 5000);
};
})();
</script>
{% block scripts %}{% endblock %}
</body>
</html>