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,214 @@
{% extends "company_base.html" %}
{% block title %}Mitarbeiter - {{ session.company_name }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Mitarbeiter verwalten</h1>
<p class="text-gray-400">Verwalte die Konten deiner Teammitglieder.</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">
{{ error }}
</div>
{% endif %}
{% if success %}
<div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded-lg mb-6">
{{ success }}
</div>
{% endif %}
<!-- Invite New Employee -->
<div class="card-bg rounded-xl border p-6 mb-8">
<h2 class="text-lg font-medium text-white mb-4">Neuen Mitarbeiter einladen</h2>
<form id="invite-form" class="flex gap-4">
<input type="email" id="invite-email"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="email@beispiel.de" required>
<button type="submit" id="invite-submit-btn"
class="btn-primary py-2 px-6 rounded-lg transition-colors flex items-center gap-2">
<svg id="invite-spinner" class="hidden animate-spin h-5 w-5 text-brand-bg-dark" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span id="invite-btn-text">Einladung senden</span>
</button>
</form>
</div>
<!-- Pending Invitations -->
{% if pending_invitations and pending_invitations|length > 0 %}
<div class="card-bg rounded-xl border p-6 mb-8">
<h2 class="text-lg font-medium text-white mb-4">Offene Einladungen</h2>
<div class="space-y-3">
{% for invitation in pending_invitations %}
<div class="flex items-center justify-between p-3 bg-brand-bg border border-gray-600 rounded-lg">
<div>
<p class="text-white">{{ invitation.email }}</p>
<p class="text-xs text-gray-500">Eingeladen am {{ invitation.created_at.strftime('%d.%m.%Y') }}</p>
</div>
<div class="flex items-center gap-2">
<span class="text-xs px-2 py-1 rounded-full bg-yellow-900/50 text-yellow-300">Ausstehend</span>
<button onclick="cancelInvitation('{{ invitation.id }}')"
class="text-gray-500 hover:text-red-400 p-1"
title="Einladung zurückziehen">
<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>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Current Employees -->
<div class="card-bg rounded-xl border p-6">
<h2 class="text-lg font-medium text-white mb-4">Aktive Mitarbeiter ({{ employees|length }})</h2>
{% if employees and employees|length > 0 %}
<div class="space-y-3">
{% for employee in employees %}
<div class="flex items-center justify-between p-4 bg-brand-bg border border-gray-600 rounded-lg">
<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 employee.linkedin_picture %}
<img src="{{ employee.linkedin_picture }}" alt="{{ employee.linkedin_name }}" class="w-full h-full object-cover" referrerpolicy="no-referrer">
{% else %}
<span class="text-brand-bg-dark font-bold">{{ (employee.display_name or employee.linkedin_name or employee.email)[0] | upper }}</span>
{% endif %}
</div>
<div>
<p class="text-white font-medium">{{ employee.display_name or employee.linkedin_name or employee.email }}</p>
<p class="text-xs text-gray-500">{{ employee.email }}</p>
</div>
</div>
<div class="flex items-center gap-3">
{% set status = employee.onboarding_status.value if employee.onboarding_status.value is defined else employee.onboarding_status %}
<span class="text-xs px-2 py-1 rounded-full {% if status == 'completed' %}bg-green-900/50 text-green-300{% else %}bg-yellow-900/50 text-yellow-300{% endif %}">
{% if status == 'completed' %}Aktiv{% else %}Onboarding{% endif %}
</span>
<button onclick="removeEmployee('{{ employee.id }}')"
class="text-gray-500 hover:text-red-400 p-1"
title="Mitarbeiter entfernen">
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-8">
<div class="w-16 h-16 bg-gray-600/30 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-500" 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>
</div>
<p class="text-gray-400">Noch keine Mitarbeiter</p>
<p class="text-sm text-gray-500">Lade dein erstes Teammitglied ein.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Send invitation
document.getElementById('invite-form').addEventListener('submit', async function(e) {
e.preventDefault();
const email = document.getElementById('invite-email').value;
const submitBtn = document.getElementById('invite-submit-btn');
const btnText = document.getElementById('invite-btn-text');
const spinner = document.getElementById('invite-spinner');
const emailInput = document.getElementById('invite-email');
// Show loading state
submitBtn.disabled = true;
emailInput.disabled = true;
spinner.classList.remove('hidden');
btnText.textContent = 'Sende...';
submitBtn.classList.add('opacity-75', 'cursor-not-allowed');
try {
const response = await fetch('/api/company/invite', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email})
});
const data = await response.json();
if (data.success) {
btnText.textContent = 'Erfolgreich!';
setTimeout(() => location.reload(), 500);
} else {
// Reset button state
submitBtn.disabled = false;
emailInput.disabled = false;
spinner.classList.add('hidden');
btnText.textContent = 'Einladung senden';
submitBtn.classList.remove('opacity-75', 'cursor-not-allowed');
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (e) {
// Reset button state
submitBtn.disabled = false;
emailInput.disabled = false;
spinner.classList.add('hidden');
btnText.textContent = 'Einladung senden';
submitBtn.classList.remove('opacity-75', 'cursor-not-allowed');
alert('Fehler beim Senden der Einladung');
}
});
// Cancel invitation
async function cancelInvitation(invitationId) {
if (!confirm('Einladung wirklich zurückziehen?')) return;
try {
const response = await fetch('/api/company/invitations/' + invitationId, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (e) {
alert('Fehler beim Zurückziehen der Einladung');
}
}
// Remove employee
async function removeEmployee(userId) {
if (!confirm('Mitarbeiter wirklich entfernen? Der Zugriff wird sofort entzogen.')) return;
try {
const response = await fetch('/api/company/employees/' + userId, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (e) {
alert('Fehler beim Entfernen des Mitarbeiters');
}
}
</script>
{% endblock %}