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

23
.env.linkedin.example Normal file
View File

@@ -0,0 +1,23 @@
# LinkedIn Auto-Posting Configuration
# Copy these lines to your .env file and fill in the values
# ==================== LinkedIn API ====================
# Create a LinkedIn app at: https://www.linkedin.com/developers/apps
# Required OAuth scopes: openid, profile, email, w_member_social
LINKEDIN_CLIENT_ID=your_linkedin_app_client_id
LINKEDIN_CLIENT_SECRET=your_linkedin_app_client_secret
LINKEDIN_REDIRECT_URI=https://yourdomain.com/settings/linkedin/callback
# ==================== Token Encryption ====================
# Generate with: python scripts/generate_encryption_key.py
# ⚠️ IMPORTANT: Never change this key after storing tokens!
ENCRYPTION_KEY=your_generated_fernet_key_here
# ==================== Notes ====================
# 1. Replace "yourdomain.com" with your actual domain
# 2. Ensure HTTPS is enabled (required for OAuth)
# 3. The redirect URI must match exactly in LinkedIn app settings
# 4. Keep these values secure and never commit to git
# 5. After setup, restart your application to load new variables

View File

@@ -28,12 +28,12 @@ RUN useradd --create-home --shell /bin/bash appuser && \
chown -R appuser:appuser /app chown -R appuser:appuser /app
USER appuser USER appuser
# Expose port # Expose port (default 8001, can be overridden by PORT env var)
EXPOSE 8000 EXPOSE 8001
# Health check # Health check (uses PORT env var with 8001 fallback)
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:8000/login', timeout=5)" || exit 1 CMD python -c "import os, httpx; port = os.getenv('PORT', '8001'); httpx.get(f'http://localhost:{port}/login', timeout=5)" || exit 1
# Run the application # Run the application (uses PORT env var with 8001 fallback)
CMD ["python", "-m", "uvicorn", "src.web.app:app", "--host", "0.0.0.0", "--port", "8000"] CMD sh -c "python -m uvicorn src.web.app:app --host 0.0.0.0 --port ${PORT:-8001}"

217
LINKEDIN_SETUP.md Normal file
View File

@@ -0,0 +1,217 @@
# LinkedIn Auto-Posting Setup Guide
This guide walks you through setting up the LinkedIn auto-posting feature.
## Overview
Employees can link their LinkedIn accounts to automatically post scheduled content directly to LinkedIn, eliminating manual copy-paste work.
## Prerequisites
1. A LinkedIn Developer account
2. Access to your server's environment variables
3. HTTPS domain (required for OAuth)
## Step 1: Create LinkedIn App
1. Go to [LinkedIn Developers](https://www.linkedin.com/developers/apps)
2. Click "Create app"
3. Fill in app details:
- **App name**: Your app name (e.g., "LinkedInWorkflow Auto-Poster")
- **LinkedIn Page**: Select your company page
- **App logo**: Upload a logo (optional)
4. Check the agreement and create the app
## Step 2: Configure OAuth Settings
1. In your app dashboard, go to the **Auth** tab
2. Add OAuth 2.0 redirect URLs:
```
https://yourdomain.com/settings/linkedin/callback
```
3. Request the following **OAuth 2.0 scopes**:
- `openid` - For user identity
- `profile` - For user profile data
- `email` - For user email
- `w_member_social` - **CRITICAL** - For posting on user's behalf
Note: `w_member_social` requires app review by LinkedIn. Submit for review if not already approved.
4. Note your **Client ID** and **Client Secret** (found in Auth tab)
## Step 3: Generate Encryption Key
Run the helper script to generate a secure encryption key:
```bash
python scripts/generate_encryption_key.py
```
Copy the generated key for the next step.
## Step 4: Configure Environment Variables
Add these variables to your `.env` file:
```env
# LinkedIn API (Custom OAuth for auto-posting)
LINKEDIN_CLIENT_ID=your_client_id_here
LINKEDIN_CLIENT_SECRET=your_client_secret_here
LINKEDIN_REDIRECT_URI=https://yourdomain.com/settings/linkedin/callback
# Token Encryption
ENCRYPTION_KEY=your_generated_fernet_key_here
```
⚠️ **Security**: Never commit these values to git! Keep them secure.
## Step 5: Run Database Migration
Apply the database migration to create the `linkedin_accounts` table:
```bash
# Using psql
psql $DATABASE_URL -f config/migrate_add_linkedin_accounts.sql
# Or via Supabase dashboard SQL editor
# Copy and paste the contents of config/migrate_add_linkedin_accounts.sql
```
## Step 6: Install Dependencies
Install the new dependencies:
```bash
pip install -r requirements.txt
```
## Step 7: Restart Application
Restart your application to load the new environment variables:
```bash
# If using systemd
sudo systemctl restart linkedinworkflow
# If using docker
docker-compose restart
# If running directly
# Stop the current process and restart
```
## Step 8: Test the Integration
1. Log in as an employee user
2. Go to **Settings** (`/settings`)
3. Click **"Mit LinkedIn verbinden"**
4. Complete the LinkedIn OAuth flow
5. Verify the account shows as connected
6. Schedule a test post
7. Wait for the scheduled time (or manually trigger the scheduler)
8. Verify the post appears on LinkedIn
## Troubleshooting
### OAuth Errors
**"Invalid redirect_uri"**
- Make sure the redirect URI in your .env matches exactly with the one configured in LinkedIn app settings
- Include the protocol (`https://`) and no trailing slash
**"Insufficient permissions"**
- Ensure `w_member_social` scope is requested and approved
- Some scopes require LinkedIn app review
### Token Errors
**"Token expired"**
- LinkedIn tokens typically last 60 days
- The system will attempt to refresh automatically
- If refresh fails, user needs to reconnect
**"Encryption error"**
- Verify `ENCRYPTION_KEY` is set in environment
- Never change the encryption key after storing tokens (or all tokens become unreadable)
### Posting Errors
**"Rate limit exceeded"**
- LinkedIn has rate limits on posting
- The system will fall back to email notification
- Wait before retrying
**"Image upload failed"**
- Check that the image URL is publicly accessible
- Supabase storage URLs must be publicly readable
- System will post without image if upload fails
## Rate Limits
LinkedIn API has the following rate limits (as of 2024):
- **Posts**: ~100 per day per user
- **API calls**: Varies by endpoint
Plan your posting schedule accordingly.
## Security Best Practices
1. **Never log tokens**: Tokens are encrypted at rest and should never be logged in plaintext
2. **HTTPS only**: OAuth requires HTTPS in production
3. **Secure cookies**: Session cookies use httponly, secure, and samesite flags
4. **Token rotation**: Encourage users to reconnect periodically
5. **Audit logging**: Monitor for suspicious activity in `linkedin_accounts.last_error`
## Architecture
```
┌─────────────────┐
│ Employee │
│ (Settings Page) │
└────────┬────────┘
│ 1. OAuth Flow
┌─────────────────────────┐
│ linkedin_accounts table │
│ (encrypted tokens) │
└────────┬────────────────┘
│ 2. Scheduler checks
┌─────────────────────────┐
│ LinkedIn API Service │
│ (UGC Posts API) │
└─────────────────────────┘
```
## Monitoring
Key metrics to monitor:
- **Token expiry**: Check `linkedin_accounts.token_expires_at`
- **API errors**: Check `linkedin_accounts.last_error`
- **Success rate**: Compare scheduled posts vs. published posts
- **Fallback rate**: How often email fallback is used
Query to check accounts needing attention:
```sql
SELECT
user_id,
linkedin_name,
last_error,
last_error_at,
token_expires_at
FROM linkedin_accounts
WHERE
(last_error IS NOT NULL OR token_expires_at < NOW() + INTERVAL '7 days')
AND is_active = true;
```
## Support
For issues or questions:
1. Check logs for detailed error messages
2. Review LinkedIn API documentation
3. Verify environment configuration
4. Test with a single user account first

536
config/fresh_migration.sql Normal file
View File

@@ -0,0 +1,536 @@
-- ==================== FRESH DATABASE MIGRATION ====================
-- LinkedIn Workflow - Complete Schema for new Supabase Database
-- Run this in the Supabase SQL Editor
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ==================== CORE TABLES ====================
-- Customers/Clients Table
CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Basic Info
name TEXT NOT NULL,
email TEXT,
company_name TEXT,
-- LinkedIn Profile
linkedin_url TEXT NOT NULL,
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
-- Email workflow fields
creator_email TEXT,
customer_email TEXT,
-- Relations
user_id UUID, -- FK added after auth setup
company_id UUID, -- FK added after companies table
writing_style_notes TEXT,
is_active BOOLEAN DEFAULT TRUE,
profile_picture TEXT
);
-- Post Types Table (for categorizing posts by type)
CREATE TABLE IF NOT EXISTS post_types (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Type Definition
name TEXT NOT NULL,
description TEXT,
identifying_hashtags TEXT[] DEFAULT '{}',
identifying_keywords TEXT[] DEFAULT '{}',
semantic_properties JSONB DEFAULT '{}'::JSONB,
-- Analysis Results
analysis JSONB,
analysis_generated_at TIMESTAMP WITH TIME ZONE,
analyzed_post_count INTEGER DEFAULT 0,
-- Status
is_active BOOLEAN DEFAULT TRUE,
UNIQUE(customer_id, name)
);
-- LinkedIn Profiles Table (scraped data)
CREATE TABLE IF NOT EXISTS linkedin_profiles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Profile Data
profile_data JSONB NOT NULL,
-- Extracted Information
name TEXT,
headline TEXT,
summary TEXT,
location TEXT,
industry TEXT,
UNIQUE(customer_id)
);
-- LinkedIn Posts Table (scraped posts)
CREATE TABLE IF NOT EXISTS linkedin_posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Post Data
post_url TEXT,
post_text TEXT NOT NULL,
post_date TIMESTAMP WITH TIME ZONE,
likes INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
shares INTEGER DEFAULT 0,
-- Raw Data
raw_data JSONB,
-- Post type classification
post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL,
classification_method TEXT,
classification_confidence FLOAT,
UNIQUE(customer_id, post_url)
);
-- Topics Table (extracted from posts)
CREATE TABLE IF NOT EXISTS topics (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Topic Info
title TEXT NOT NULL,
description TEXT,
category TEXT,
-- AI Extraction
extracted_from_post_id UUID REFERENCES linkedin_posts(id),
extraction_confidence FLOAT,
-- Status
is_used BOOLEAN DEFAULT FALSE,
used_at TIMESTAMP WITH TIME ZONE,
-- Target post type
target_post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL
);
-- Profile Analysis Table (AI-generated insights)
CREATE TABLE IF NOT EXISTS profile_analyses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Analysis Results
writing_style JSONB NOT NULL,
tone_analysis JSONB NOT NULL,
topic_patterns JSONB NOT NULL,
audience_insights JSONB NOT NULL,
-- Full Analysis
full_analysis JSONB NOT NULL,
UNIQUE(customer_id)
);
-- Research Results Table
CREATE TABLE IF NOT EXISTS research_results (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Research Data
query TEXT NOT NULL,
results JSONB NOT NULL,
-- Topic Suggestions
suggested_topics JSONB NOT NULL,
-- Metadata
source TEXT DEFAULT 'perplexity',
-- Target post type
target_post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL
);
-- Generated Posts Table
CREATE TABLE IF NOT EXISTS generated_posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Topic
topic_id UUID REFERENCES topics(id),
topic_title TEXT NOT NULL,
-- Post Content
post_content TEXT NOT NULL,
-- Generation Metadata
iterations INTEGER DEFAULT 0,
writer_versions JSONB DEFAULT '[]'::JSONB,
critic_feedback JSONB DEFAULT '[]'::JSONB,
-- Status: draft -> approved -> ready -> scheduled -> published
-- draft: AI generated, waiting for review
-- approved: User edited, email sent to customer
-- ready: Customer approved via email, can be scheduled
-- scheduled: Scheduled in calendar
-- published: Actually published to LinkedIn
-- rejected: Customer rejected, back to draft
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'approved', 'ready', 'scheduled', 'published', 'rejected')),
approved_at TIMESTAMP WITH TIME ZONE,
published_at TIMESTAMP WITH TIME ZONE,
-- Post type
post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL,
-- Scheduling
scheduled_at TIMESTAMP WITH TIME ZONE,
scheduled_by_user_id UUID -- FK added after auth setup
);
-- ==================== COMPANIES & PROFILES (Supabase Auth) ====================
-- Companies Table
CREATE TABLE IF NOT EXISTS companies (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Company Data
name TEXT NOT NULL,
description TEXT,
website TEXT,
industry TEXT,
-- Strategy
company_strategy JSONB DEFAULT '{}'::JSONB,
owner_user_id UUID NOT NULL, -- FK to auth.users
onboarding_completed BOOLEAN DEFAULT FALSE
);
-- Profiles Table (extends auth.users)
CREATE TABLE IF NOT EXISTS profiles (
id UUID PRIMARY KEY, -- Same as auth.users.id
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Account Type
account_type TEXT NOT NULL DEFAULT 'ghostwriter',
-- Display name
display_name TEXT,
-- Onboarding
onboarding_status TEXT DEFAULT 'pending',
onboarding_data JSONB DEFAULT '{}'::JSONB,
-- Links
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
company_id UUID REFERENCES companies(id) ON DELETE SET NULL
);
-- Invitations Table
CREATE TABLE IF NOT EXISTS invitations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
email TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
invited_by_user_id UUID NOT NULL, -- FK to auth.users
status TEXT DEFAULT 'pending',
accepted_at TIMESTAMP WITH TIME ZONE,
accepted_by_user_id UUID -- FK to auth.users
);
-- Example Posts Table
CREATE TABLE IF NOT EXISTS example_posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
post_text TEXT NOT NULL,
source TEXT DEFAULT 'manual',
source_linkedin_url TEXT,
post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL
);
-- Reference Profiles Table
CREATE TABLE IF NOT EXISTS reference_profiles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
linkedin_url TEXT NOT NULL,
name TEXT,
posts_scraped INTEGER DEFAULT 0,
UNIQUE(customer_id, linkedin_url)
);
-- ==================== ADD FOREIGN KEYS (after all tables exist) ====================
-- Add FK from profiles to auth.users
ALTER TABLE profiles
ADD CONSTRAINT fk_profiles_auth_users
FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE;
-- Add FK from companies to auth.users
ALTER TABLE companies
ADD CONSTRAINT fk_companies_owner
FOREIGN KEY (owner_user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
-- Add FK from customers to auth.users and companies
ALTER TABLE customers
ADD CONSTRAINT fk_customers_user
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE customers
ADD CONSTRAINT fk_customers_company
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL;
-- Add FK from generated_posts to auth.users
ALTER TABLE generated_posts
ADD CONSTRAINT fk_generated_posts_scheduled_by
FOREIGN KEY (scheduled_by_user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
-- Add FK from invitations to auth.users
ALTER TABLE invitations
ADD CONSTRAINT fk_invitations_invited_by
FOREIGN KEY (invited_by_user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE invitations
ADD CONSTRAINT fk_invitations_accepted_by
FOREIGN KEY (accepted_by_user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
-- ==================== INDEXES ====================
-- Customers
CREATE INDEX idx_customers_linkedin_url ON customers(linkedin_url);
CREATE INDEX idx_customers_user_id ON customers(user_id);
CREATE INDEX idx_customers_company_id ON customers(company_id);
-- LinkedIn data
CREATE INDEX idx_linkedin_profiles_customer_id ON linkedin_profiles(customer_id);
CREATE INDEX idx_linkedin_posts_customer_id ON linkedin_posts(customer_id);
CREATE INDEX idx_linkedin_posts_post_type_id ON linkedin_posts(post_type_id);
-- Topics
CREATE INDEX idx_topics_customer_id ON topics(customer_id);
CREATE INDEX idx_topics_is_used ON topics(is_used);
CREATE INDEX idx_topics_target_post_type_id ON topics(target_post_type_id);
-- Profile analyses & research
CREATE INDEX idx_profile_analyses_customer_id ON profile_analyses(customer_id);
CREATE INDEX idx_research_results_customer_id ON research_results(customer_id);
CREATE INDEX idx_research_results_target_post_type_id ON research_results(target_post_type_id);
-- Generated posts
CREATE INDEX idx_generated_posts_customer_id ON generated_posts(customer_id);
CREATE INDEX idx_generated_posts_status ON generated_posts(status);
CREATE INDEX idx_generated_posts_post_type_id ON generated_posts(post_type_id);
CREATE INDEX idx_generated_posts_scheduled_at ON generated_posts(scheduled_at)
WHERE scheduled_at IS NOT NULL AND status = 'scheduled';
-- Post types
CREATE INDEX idx_post_types_customer_id ON post_types(customer_id);
CREATE INDEX idx_post_types_is_active ON post_types(is_active);
-- Profiles
CREATE INDEX idx_profiles_account_type ON profiles(account_type);
CREATE INDEX idx_profiles_onboarding_status ON profiles(onboarding_status);
CREATE INDEX idx_profiles_customer_id ON profiles(customer_id);
CREATE INDEX idx_profiles_company_id ON profiles(company_id);
-- Companies
CREATE INDEX idx_companies_owner_user_id ON companies(owner_user_id);
-- Invitations
CREATE INDEX idx_invitations_token ON invitations(token);
CREATE INDEX idx_invitations_company_id ON invitations(company_id);
CREATE INDEX idx_invitations_email ON invitations(email);
CREATE INDEX idx_invitations_status ON invitations(status);
-- Example posts & reference profiles
CREATE INDEX idx_example_posts_customer_id ON example_posts(customer_id);
CREATE INDEX idx_reference_profiles_customer_id ON reference_profiles(customer_id);
-- ==================== TRIGGERS ====================
-- Updated_at trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Add triggers
CREATE TRIGGER update_customers_updated_at
BEFORE UPDATE ON customers
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_post_types_updated_at
BEFORE UPDATE ON post_types
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_profiles_updated_at
BEFORE UPDATE ON profiles
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_companies_updated_at
BEFORE UPDATE ON companies
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- ==================== AUTO-CREATE PROFILE ON SIGNUP ====================
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, account_type, onboarding_status)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'account_type', 'ghostwriter'),
'pending'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- ==================== ROW LEVEL SECURITY ====================
-- Enable RLS
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE companies ENABLE ROW LEVEL SECURITY;
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE generated_posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE invitations ENABLE ROW LEVEL SECURITY;
-- Profiles policies
CREATE POLICY "Users can view own profile"
ON profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE
USING (auth.uid() = id);
CREATE POLICY "Service role has full access to profiles"
ON profiles FOR ALL
USING (auth.jwt() ->> 'role' = 'service_role');
-- Companies policies
CREATE POLICY "Company owners can manage their company"
ON companies FOR ALL
USING (auth.uid() = owner_user_id);
CREATE POLICY "Employees can view their company"
ON companies FOR SELECT
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.company_id = companies.id
)
);
CREATE POLICY "Service role has full access to companies"
ON companies FOR ALL
USING (auth.jwt() ->> 'role' = 'service_role');
-- Customers policies
CREATE POLICY "Users can manage own customers"
ON customers FOR ALL
USING (auth.uid() = user_id);
CREATE POLICY "Company members can view company customers"
ON customers FOR SELECT
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.company_id = customers.company_id
)
);
CREATE POLICY "Service role has full access to customers"
ON customers FOR ALL
USING (auth.jwt() ->> 'role' = 'service_role');
-- Generated posts policies
CREATE POLICY "Users can manage posts of their customers"
ON generated_posts FOR ALL
USING (
EXISTS (
SELECT 1 FROM customers
WHERE customers.id = generated_posts.customer_id
AND customers.user_id = auth.uid()
)
);
CREATE POLICY "Company members can view company posts"
ON generated_posts FOR SELECT
USING (
EXISTS (
SELECT 1 FROM customers
JOIN profiles ON profiles.company_id = customers.company_id
WHERE customers.id = generated_posts.customer_id
AND profiles.id = auth.uid()
)
);
CREATE POLICY "Service role has full access to generated_posts"
ON generated_posts FOR ALL
USING (auth.jwt() ->> 'role' = 'service_role');
-- Invitations policies
CREATE POLICY "Company owners can manage invitations"
ON invitations FOR ALL
USING (
EXISTS (
SELECT 1 FROM companies
WHERE companies.id = invitations.company_id
AND companies.owner_user_id = auth.uid()
)
);
CREATE POLICY "Anyone can view invitation by token"
ON invitations FOR SELECT
USING (true);
CREATE POLICY "Service role has full access to invitations"
ON invitations FOR ALL
USING (auth.jwt() ->> 'role' = 'service_role');
-- ==================== DONE ====================
-- Run this script in the Supabase SQL Editor
-- Make sure to configure your .env with the new Supabase URL and keys

View File

@@ -0,0 +1,94 @@
-- Migration: Add License Key System with Company Limits
-- This migration adds:
-- 1. license_keys table for managing license keys
-- 2. company_daily_quotas table for tracking daily usage
-- 3. license_key_id reference in companies table
-- ==================== LICENSE KEYS TABLE ====================
CREATE TABLE IF NOT EXISTS license_keys (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
key TEXT UNIQUE NOT NULL,
description TEXT,
-- Limits (stored here, NOT in companies table)
max_employees INT NOT NULL DEFAULT 5,
max_posts_per_day INT NOT NULL DEFAULT 10,
max_researches_per_day INT NOT NULL DEFAULT 5,
-- Usage tracking
used BOOLEAN DEFAULT FALSE,
company_id UUID REFERENCES companies(id) ON DELETE SET NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Index for fast lookups
CREATE INDEX IF NOT EXISTS idx_license_keys_key ON license_keys(key);
CREATE INDEX IF NOT EXISTS idx_license_keys_used ON license_keys(used);
CREATE INDEX IF NOT EXISTS idx_license_keys_company_id ON license_keys(company_id);
-- RLS Policies (Admin only)
ALTER TABLE license_keys ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Service role has full access to license_keys"
ON license_keys FOR ALL
USING (auth.jwt()->>'role' = 'service_role');
-- ==================== EXTEND COMPANIES TABLE ====================
-- Add license key reference to companies table
-- (limits are stored in license_keys table, not duplicated here)
ALTER TABLE companies ADD COLUMN IF NOT EXISTS license_key_id UUID REFERENCES license_keys(id);
-- Index
CREATE INDEX IF NOT EXISTS idx_companies_license_key_id ON companies(license_key_id);
-- ==================== COMPANY DAILY QUOTAS TABLE ====================
-- Track daily usage per company
CREATE TABLE IF NOT EXISTS company_daily_quotas (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
date DATE NOT NULL,
posts_created INT DEFAULT 0,
researches_created INT DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(company_id, date)
);
-- Index for fast daily lookups
CREATE INDEX IF NOT EXISTS idx_company_daily_quotas_company_date ON company_daily_quotas(company_id, date);
-- RLS Policies
ALTER TABLE company_daily_quotas ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own company quotas"
ON company_daily_quotas FOR SELECT
USING (
company_id IN (
SELECT company_id FROM profiles WHERE id = auth.uid()
)
);
CREATE POLICY "Service role has full access to company_daily_quotas"
ON company_daily_quotas FOR ALL
USING (auth.jwt()->>'role' = 'service_role');
-- ==================== TRIGGERS ====================
DROP TRIGGER IF EXISTS update_license_keys_updated_at ON license_keys;
CREATE TRIGGER update_license_keys_updated_at
BEFORE UPDATE ON license_keys
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_company_daily_quotas_updated_at ON company_daily_quotas;
CREATE TRIGGER update_company_daily_quotas_updated_at
BEFORE UPDATE ON company_daily_quotas
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,68 @@
-- Migration: Add LinkedIn accounts for auto-posting
-- Description: Allows employees to link their LinkedIn accounts for automatic post publishing
CREATE TABLE IF NOT EXISTS linkedin_accounts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- LinkedIn Identity
linkedin_user_id TEXT NOT NULL,
linkedin_vanity_name TEXT,
linkedin_name TEXT,
linkedin_picture TEXT,
-- OAuth Tokens (encrypted)
access_token TEXT NOT NULL,
refresh_token TEXT,
token_expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
granted_scopes TEXT[] DEFAULT '{}',
-- Status
is_active BOOLEAN DEFAULT TRUE,
last_used_at TIMESTAMP WITH TIME ZONE,
last_error TEXT,
last_error_at TIMESTAMP WITH TIME ZONE,
UNIQUE(user_id) -- Only one account per user
);
CREATE INDEX idx_linkedin_accounts_user_id ON linkedin_accounts(user_id);
-- Enable RLS
ALTER TABLE linkedin_accounts ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see their own LinkedIn account
CREATE POLICY "Users can view own linkedin account"
ON linkedin_accounts FOR SELECT
USING (auth.uid() = user_id);
-- Policy: Users can insert their own LinkedIn account
CREATE POLICY "Users can insert own linkedin account"
ON linkedin_accounts FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Policy: Users can update their own LinkedIn account
CREATE POLICY "Users can update own linkedin account"
ON linkedin_accounts FOR UPDATE
USING (auth.uid() = user_id);
-- Policy: Users can delete their own LinkedIn account
CREATE POLICY "Users can delete own linkedin account"
ON linkedin_accounts FOR DELETE
USING (auth.uid() = user_id);
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_linkedin_accounts_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_linkedin_accounts_updated_at
BEFORE UPDATE ON linkedin_accounts
FOR EACH ROW
EXECUTE FUNCTION update_linkedin_accounts_updated_at();

View File

@@ -0,0 +1,8 @@
-- Add metadata field to generated_posts table
-- This field stores additional information like LinkedIn post URLs, auto-posting status, etc.
ALTER TABLE generated_posts
ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::JSONB;
-- Add comment
COMMENT ON COLUMN generated_posts.metadata IS 'Additional metadata (LinkedIn post URL, auto-posting status, etc.)';

View File

@@ -0,0 +1,314 @@
-- Migration: Remove customers table, move everything to user_id on profiles
-- BACKUP YOUR DATABASE BEFORE RUNNING THIS!
-- Run with: psql -d your_db -f config/migrate_remove_customers.sql
BEGIN;
-- ============================================================
-- 1. Add new columns to profiles (from customers)
-- ============================================================
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS linkedin_url TEXT,
ADD COLUMN IF NOT EXISTS writing_style_notes TEXT,
ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::JSONB,
ADD COLUMN IF NOT EXISTS profile_picture TEXT,
ADD COLUMN IF NOT EXISTS creator_email TEXT,
ADD COLUMN IF NOT EXISTS customer_email TEXT,
ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE;
-- ============================================================
-- 2. Migrate data from customers to profiles
-- ============================================================
UPDATE profiles
SET
linkedin_url = c.linkedin_url,
writing_style_notes = c.writing_style_notes,
metadata = c.metadata,
profile_picture = c.profile_picture,
creator_email = c.creator_email,
customer_email = c.customer_email,
is_active = c.is_active
FROM customers c
WHERE profiles.customer_id = c.id;
-- Also populate display_name from customer.name where profiles.display_name is null
UPDATE profiles
SET display_name = c.name
FROM customers c
WHERE profiles.customer_id = c.id
AND profiles.display_name IS NULL;
-- ============================================================
-- 3. Add user_id column to all content tables
-- ============================================================
ALTER TABLE linkedin_profiles
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE linkedin_posts
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE topics
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE post_types
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE profile_analyses
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE research_results
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE generated_posts
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE example_posts
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE reference_profiles
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
-- ============================================================
-- 4. Populate user_id via profiles.customer_id mapping
-- ============================================================
UPDATE linkedin_profiles lp
SET user_id = p.id
FROM profiles p
WHERE lp.customer_id = p.customer_id
AND p.customer_id IS NOT NULL;
UPDATE linkedin_posts lp
SET user_id = p.id
FROM profiles p
WHERE lp.customer_id = p.customer_id
AND p.customer_id IS NOT NULL;
UPDATE topics t
SET user_id = p.id
FROM profiles p
WHERE t.customer_id = p.customer_id
AND p.customer_id IS NOT NULL;
UPDATE post_types pt
SET user_id = p.id
FROM profiles p
WHERE pt.customer_id = p.customer_id
AND p.customer_id IS NOT NULL;
UPDATE profile_analyses pa
SET user_id = p.id
FROM profiles p
WHERE pa.customer_id = p.customer_id
AND p.customer_id IS NOT NULL;
UPDATE research_results rr
SET user_id = p.id
FROM profiles p
WHERE rr.customer_id = p.customer_id
AND p.customer_id IS NOT NULL;
UPDATE generated_posts gp
SET user_id = p.id
FROM profiles p
WHERE gp.customer_id = p.customer_id
AND p.customer_id IS NOT NULL;
UPDATE example_posts ep
SET user_id = p.id
FROM profiles p
WHERE ep.customer_id = p.customer_id
AND p.customer_id IS NOT NULL;
UPDATE reference_profiles rp
SET user_id = p.id
FROM profiles p
WHERE rp.customer_id = p.customer_id
AND p.customer_id IS NOT NULL;
-- ============================================================
-- 5. Drop old unique constraints (customer_id based)
-- ============================================================
ALTER TABLE linkedin_profiles DROP CONSTRAINT IF EXISTS linkedin_profiles_customer_id_key;
ALTER TABLE profile_analyses DROP CONSTRAINT IF EXISTS profile_analyses_customer_id_key;
ALTER TABLE post_types DROP CONSTRAINT IF EXISTS post_types_customer_id_name_key;
ALTER TABLE linkedin_posts DROP CONSTRAINT IF EXISTS linkedin_posts_customer_id_post_url_key;
ALTER TABLE reference_profiles DROP CONSTRAINT IF EXISTS reference_profiles_customer_id_linkedin_url_key;
-- ============================================================
-- 6. Set user_id NOT NULL (after data migration)
-- ============================================================
-- Delete orphan rows that have no user_id (data without a profile link)
DELETE FROM linkedin_profiles WHERE user_id IS NULL;
DELETE FROM linkedin_posts WHERE user_id IS NULL;
DELETE FROM topics WHERE user_id IS NULL;
DELETE FROM post_types WHERE user_id IS NULL;
DELETE FROM profile_analyses WHERE user_id IS NULL;
DELETE FROM research_results WHERE user_id IS NULL;
DELETE FROM generated_posts WHERE user_id IS NULL;
DELETE FROM example_posts WHERE user_id IS NULL;
DELETE FROM reference_profiles WHERE user_id IS NULL;
ALTER TABLE linkedin_profiles ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE linkedin_posts ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE topics ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE post_types ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE profile_analyses ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE research_results ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE generated_posts ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE example_posts ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE reference_profiles ALTER COLUMN user_id SET NOT NULL;
-- ============================================================
-- 7. Add new unique constraints (user_id based)
-- ============================================================
ALTER TABLE linkedin_profiles ADD CONSTRAINT linkedin_profiles_user_id_key UNIQUE (user_id);
ALTER TABLE profile_analyses ADD CONSTRAINT profile_analyses_user_id_key UNIQUE (user_id);
ALTER TABLE post_types ADD CONSTRAINT post_types_user_id_name_key UNIQUE (user_id, name);
ALTER TABLE linkedin_posts ADD CONSTRAINT linkedin_posts_user_id_post_url_key UNIQUE (user_id, post_url);
ALTER TABLE reference_profiles ADD CONSTRAINT reference_profiles_user_id_linkedin_url_key UNIQUE (user_id, linkedin_url);
-- ============================================================
-- 8. Drop RLS policies that reference customer_id
-- ============================================================
-- Drop ALL policies on generated_posts (we'll recreate them with user_id)
DROP POLICY IF EXISTS "Users can manage posts of their customers" ON generated_posts;
DROP POLICY IF EXISTS "Company members can view company posts" ON generated_posts;
DROP POLICY IF EXISTS "Service role has full access to generated_posts" ON generated_posts;
-- Drop policies on customers table (will be dropped anyway)
DROP POLICY IF EXISTS "Users can manage own customers" ON customers;
DROP POLICY IF EXISTS "Company members can view company customers" ON customers;
DROP POLICY IF EXISTS "Service role has full access to customers" ON customers;
-- ============================================================
-- 8b. Drop users view (depends on profiles.customer_id)
-- ============================================================
DROP VIEW IF EXISTS users;
-- ============================================================
-- 9. Drop old customer_id columns from content tables
-- ============================================================
ALTER TABLE linkedin_profiles DROP COLUMN IF EXISTS customer_id;
ALTER TABLE linkedin_posts DROP COLUMN IF EXISTS customer_id;
ALTER TABLE topics DROP COLUMN IF EXISTS customer_id;
ALTER TABLE post_types DROP COLUMN IF EXISTS customer_id;
ALTER TABLE profile_analyses DROP COLUMN IF EXISTS customer_id;
ALTER TABLE research_results DROP COLUMN IF EXISTS customer_id;
ALTER TABLE generated_posts DROP COLUMN IF EXISTS customer_id;
ALTER TABLE example_posts DROP COLUMN IF EXISTS customer_id;
ALTER TABLE reference_profiles DROP COLUMN IF EXISTS customer_id;
-- Drop customer_id from profiles
ALTER TABLE profiles DROP COLUMN IF EXISTS customer_id;
-- Drop customer_id from api_usage_logs
ALTER TABLE api_usage_logs DROP COLUMN IF EXISTS customer_id;
-- ============================================================
-- 10. Create new RLS policies using user_id
-- ============================================================
-- Generated posts policies (using user_id instead of customer_id)
CREATE POLICY "Users can manage own posts"
ON generated_posts FOR ALL
USING (user_id = auth.uid());
CREATE POLICY "Company members can view company posts"
ON generated_posts FOR SELECT
USING (
EXISTS (
SELECT 1 FROM profiles p1
JOIN profiles p2 ON p1.company_id = p2.company_id
WHERE p1.id = auth.uid()
AND p2.id = generated_posts.user_id
AND p1.company_id IS NOT NULL
)
);
CREATE POLICY "Service role has full access to generated_posts"
ON generated_posts FOR ALL
USING (auth.jwt() ->> 'role' = 'service_role');
-- ============================================================
-- 11. Drop old indexes and create new ones
-- ============================================================
DROP INDEX IF EXISTS idx_linkedin_profiles_customer_id;
DROP INDEX IF EXISTS idx_linkedin_posts_customer_id;
DROP INDEX IF EXISTS idx_topics_customer_id;
DROP INDEX IF EXISTS idx_post_types_customer_id;
DROP INDEX IF EXISTS idx_profile_analyses_customer_id;
DROP INDEX IF EXISTS idx_research_results_customer_id;
DROP INDEX IF EXISTS idx_generated_posts_customer_id;
DROP INDEX IF EXISTS idx_example_posts_customer_id;
DROP INDEX IF EXISTS idx_reference_profiles_customer_id;
DROP INDEX IF EXISTS idx_profiles_customer_id;
DROP INDEX IF EXISTS idx_customers_linkedin_url;
DROP INDEX IF EXISTS idx_customers_user_id;
DROP INDEX IF EXISTS idx_customers_company_id;
DROP INDEX IF EXISTS idx_api_usage_logs_customer_id;
CREATE INDEX IF NOT EXISTS idx_linkedin_profiles_user_id ON linkedin_profiles(user_id);
CREATE INDEX IF NOT EXISTS idx_linkedin_posts_user_id ON linkedin_posts(user_id);
CREATE INDEX IF NOT EXISTS idx_topics_user_id ON topics(user_id);
CREATE INDEX IF NOT EXISTS idx_post_types_user_id ON post_types(user_id);
CREATE INDEX IF NOT EXISTS idx_profile_analyses_user_id ON profile_analyses(user_id);
CREATE INDEX IF NOT EXISTS idx_research_results_user_id ON research_results(user_id);
CREATE INDEX IF NOT EXISTS idx_generated_posts_user_id ON generated_posts(user_id);
CREATE INDEX IF NOT EXISTS idx_example_posts_user_id ON example_posts(user_id);
CREATE INDEX IF NOT EXISTS idx_reference_profiles_user_id ON reference_profiles(user_id);
-- ============================================================
-- 12. Drop customers table
-- ============================================================
-- Remove trigger first
DROP TRIGGER IF EXISTS update_customers_updated_at ON customers;
DROP TABLE IF EXISTS customers CASCADE;
-- ============================================================
-- 13. Recreate users view (without customer_id)
-- ============================================================
CREATE OR REPLACE VIEW users AS
SELECT
au.id,
au.created_at,
au.updated_at,
au.email,
au.raw_user_meta_data->>'sub' AS linkedin_sub,
au.raw_user_meta_data->>'vanityName' AS linkedin_vanity_name,
au.raw_user_meta_data->>'name' AS linkedin_name,
au.raw_user_meta_data->>'picture' AS linkedin_picture,
CASE
WHEN au.raw_user_meta_data->>'iss' LIKE '%linkedin%' THEN 'linkedin_oauth'
ELSE 'email_password'
END AS auth_method,
COALESCE(p.account_type, 'ghostwriter') AS account_type,
p.display_name,
COALESCE(p.onboarding_status, 'pending') AS onboarding_status,
COALESCE(p.onboarding_data, '{}'::jsonb) AS onboarding_data,
p.company_id,
p.linkedin_url,
p.writing_style_notes,
p.metadata,
p.profile_picture,
p.creator_email,
p.customer_email,
p.is_active,
au.email_confirmed_at IS NOT NULL AS email_verified
FROM auth.users au
LEFT JOIN profiles p ON au.id = p.id;
COMMIT;

View File

@@ -0,0 +1,7 @@
-- Migration: Remove UNIQUE constraint from customers.linkedin_url
-- This allows multiple customers (different ghostwriters/companies) to share the same LinkedIn URL.
-- Each user context gets its own independent Customer record.
ALTER TABLE customers DROP CONSTRAINT IF EXISTS customers_linkedin_url_key;
-- The existing index idx_customers_linkedin_url remains for fast lookups (non-unique).

View File

@@ -0,0 +1,210 @@
-- Migration Script: From custom users table to Supabase Auth
-- ============================================================
-- IMPORTANT: Run these steps IN ORDER, one at a time!
-- ============================================================
-- ============================================================
-- STEP 1: Backup the old users table (run this first!)
-- ============================================================
-- Create backup of existing users table
CREATE TABLE IF NOT EXISTS users_backup AS SELECT * FROM users;
-- Verify backup was created
SELECT COUNT(*) as backup_count FROM users_backup;
-- ============================================================
-- STEP 2: Create profiles table
-- ============================================================
CREATE TABLE IF NOT EXISTS profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
account_type TEXT NOT NULL DEFAULT 'ghostwriter',
display_name TEXT,
onboarding_status TEXT DEFAULT 'pending',
onboarding_data JSONB DEFAULT '{}'::JSONB,
customer_id UUID,
company_id UUID
);
-- ============================================================
-- STEP 3: Drop constraints that reference the old users table
-- ============================================================
-- Drop FK constraints from companies table
ALTER TABLE companies DROP CONSTRAINT IF EXISTS companies_owner_user_id_fkey;
-- Drop FK constraints from invitations table
ALTER TABLE invitations DROP CONSTRAINT IF EXISTS invitations_invited_by_user_id_fkey;
ALTER TABLE invitations DROP CONSTRAINT IF EXISTS invitations_accepted_by_user_id_fkey;
-- Drop FK constraints from customers table
ALTER TABLE customers DROP CONSTRAINT IF EXISTS customers_user_id_fkey;
-- ============================================================
-- STEP 4: Rename old users table
-- ============================================================
ALTER TABLE IF EXISTS users RENAME TO users_old;
-- ============================================================
-- STEP 5: Create the users VIEW
-- ============================================================
CREATE OR REPLACE VIEW users AS
SELECT
p.id,
p.created_at,
p.updated_at,
au.email,
NULL::TEXT as password_hash,
CASE
WHEN au.raw_app_meta_data->>'provider' = 'linkedin_oidc' THEN 'linkedin_oauth'
ELSE 'email_password'
END as auth_method,
au.raw_user_meta_data->>'sub' as linkedin_sub,
au.raw_user_meta_data->>'vanityName' as linkedin_vanity_name,
COALESCE(au.raw_user_meta_data->>'name', au.raw_user_meta_data->>'full_name') as linkedin_name,
au.raw_user_meta_data->>'picture' as linkedin_picture,
p.account_type,
p.display_name,
p.onboarding_status,
p.onboarding_data,
p.customer_id,
p.company_id,
au.email_confirmed_at IS NOT NULL as email_verified,
NULL::TEXT as email_verification_token,
NULL::TIMESTAMP WITH TIME ZONE as email_verification_expires_at
FROM profiles p
JOIN auth.users au ON p.id = au.id;
-- ============================================================
-- STEP 6: Create the trigger for new users
-- ============================================================
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, account_type, onboarding_status, display_name)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'account_type', 'ghostwriter'),
'pending',
NEW.raw_user_meta_data->>'display_name'
)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- ============================================================
-- STEP 7: Re-add FK constraints pointing to auth.users
-- ============================================================
-- Add FK to companies
ALTER TABLE companies
ADD CONSTRAINT companies_owner_user_id_fkey
FOREIGN KEY (owner_user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
-- Add FK to invitations
ALTER TABLE invitations
ADD CONSTRAINT invitations_invited_by_user_id_fkey
FOREIGN KEY (invited_by_user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE invitations
ADD CONSTRAINT invitations_accepted_by_user_id_fkey
FOREIGN KEY (accepted_by_user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
-- Add FK to customers
ALTER TABLE customers
ADD CONSTRAINT customers_user_id_fkey
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
-- Add FK from profiles to companies
ALTER TABLE profiles
ADD CONSTRAINT fk_profiles_company
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL;
-- Add FK from profiles to customers
ALTER TABLE profiles
ADD CONSTRAINT fk_profiles_customer
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE SET NULL;
-- ============================================================
-- STEP 8: Create indexes on profiles
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_profiles_account_type ON profiles(account_type);
CREATE INDEX IF NOT EXISTS idx_profiles_onboarding_status ON profiles(onboarding_status);
CREATE INDEX IF NOT EXISTS idx_profiles_customer_id ON profiles(customer_id);
CREATE INDEX IF NOT EXISTS idx_profiles_company_id ON profiles(company_id);
-- ============================================================
-- STEP 9: Create updated_at trigger for profiles
-- ============================================================
-- Make sure the function exists
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_profiles_updated_at ON profiles;
CREATE TRIGGER update_profiles_updated_at
BEFORE UPDATE ON profiles
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- STEP 10: Enable RLS on profiles
-- ============================================================
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Drop existing policies if they exist
DROP POLICY IF EXISTS "Users can view own profile" ON profiles;
DROP POLICY IF EXISTS "Users can update own profile" ON profiles;
DROP POLICY IF EXISTS "Service role has full access to profiles" ON profiles;
-- Profiles: Users can read/update their own profile
CREATE POLICY "Users can view own profile"
ON profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE
USING (auth.uid() = id);
-- Service role can do everything (for admin operations)
CREATE POLICY "Service role has full access to profiles"
ON profiles FOR ALL
USING (true);
-- ============================================================
-- DONE!
--
-- The old users table has been renamed to 'users_old'.
-- A backup was created as 'users_backup'.
--
-- New users will be created via Supabase Auth and will
-- automatically get a profile via the trigger.
--
-- To clean up later (after verifying everything works):
-- DROP TABLE IF EXISTS users_old;
-- DROP TABLE IF EXISTS users_backup;
-- ============================================================
-- Verify the migration
SELECT 'Migration complete!' as status;
SELECT COUNT(*) as profiles_count FROM profiles;
SELECT COUNT(*) as auth_users_count FROM auth.users;

View File

@@ -0,0 +1,257 @@
-- Migration Script v2: From custom users table to Supabase Auth
-- ============================================================
-- This version handles orphaned references properly
-- ============================================================
-- ============================================================
-- STEP 1: Backup tables
-- ============================================================
CREATE TABLE IF NOT EXISTS users_backup AS SELECT * FROM users;
CREATE TABLE IF NOT EXISTS customers_backup AS SELECT * FROM customers;
CREATE TABLE IF NOT EXISTS companies_backup AS SELECT * FROM companies;
CREATE TABLE IF NOT EXISTS invitations_backup AS SELECT * FROM invitations;
SELECT 'Backups created' as status;
-- ============================================================
-- STEP 2: Create profiles table
-- ============================================================
CREATE TABLE IF NOT EXISTS profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
account_type TEXT NOT NULL DEFAULT 'ghostwriter',
display_name TEXT,
onboarding_status TEXT DEFAULT 'pending',
onboarding_data JSONB DEFAULT '{}'::JSONB,
customer_id UUID,
company_id UUID
);
SELECT 'Profiles table created' as status;
-- ============================================================
-- STEP 3: Drop ALL constraints that reference the old users table
-- ============================================================
ALTER TABLE companies DROP CONSTRAINT IF EXISTS companies_owner_user_id_fkey;
ALTER TABLE invitations DROP CONSTRAINT IF EXISTS invitations_invited_by_user_id_fkey;
ALTER TABLE invitations DROP CONSTRAINT IF EXISTS invitations_accepted_by_user_id_fkey;
ALTER TABLE customers DROP CONSTRAINT IF EXISTS customers_user_id_fkey;
-- Also drop the FK from users to companies if it exists
ALTER TABLE users DROP CONSTRAINT IF EXISTS fk_users_company;
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_company_id_fkey;
SELECT 'Old constraints dropped' as status;
-- ============================================================
-- STEP 4: Clear orphaned references BEFORE adding new constraints
-- Set user_id to NULL where user doesn't exist in auth.users
-- ============================================================
-- Clear customers.user_id where user doesn't exist in auth.users
UPDATE customers
SET user_id = NULL
WHERE user_id IS NOT NULL
AND user_id NOT IN (SELECT id FROM auth.users);
-- Clear companies.owner_user_id - but we can't set to NULL (NOT NULL constraint)
-- So we need to delete companies with orphaned owners, or skip FK for now
-- Let's check if there are any orphaned companies first
SELECT 'Orphaned companies:' as info, COUNT(*) as count
FROM companies
WHERE owner_user_id NOT IN (SELECT id FROM auth.users);
-- Delete companies with orphaned owner_user_id (they can't be used anyway)
DELETE FROM companies
WHERE owner_user_id NOT IN (SELECT id FROM auth.users);
-- Clear invitations with orphaned user references
UPDATE invitations
SET accepted_by_user_id = NULL
WHERE accepted_by_user_id IS NOT NULL
AND accepted_by_user_id NOT IN (SELECT id FROM auth.users);
DELETE FROM invitations
WHERE invited_by_user_id NOT IN (SELECT id FROM auth.users);
SELECT 'Orphaned references cleaned' as status;
-- ============================================================
-- STEP 5: Rename old users table
-- ============================================================
ALTER TABLE IF EXISTS users RENAME TO users_old;
SELECT 'Old users table renamed to users_old' as status;
-- ============================================================
-- STEP 6: Create the users VIEW
-- ============================================================
CREATE OR REPLACE VIEW users AS
SELECT
p.id,
p.created_at,
p.updated_at,
au.email,
NULL::TEXT as password_hash,
CASE
WHEN au.raw_app_meta_data->>'provider' = 'linkedin_oidc' THEN 'linkedin_oauth'
ELSE 'email_password'
END as auth_method,
au.raw_user_meta_data->>'sub' as linkedin_sub,
au.raw_user_meta_data->>'vanityName' as linkedin_vanity_name,
COALESCE(au.raw_user_meta_data->>'name', au.raw_user_meta_data->>'full_name') as linkedin_name,
au.raw_user_meta_data->>'picture' as linkedin_picture,
p.account_type,
p.display_name,
p.onboarding_status,
p.onboarding_data,
p.customer_id,
p.company_id,
au.email_confirmed_at IS NOT NULL as email_verified,
NULL::TEXT as email_verification_token,
NULL::TIMESTAMP WITH TIME ZONE as email_verification_expires_at
FROM profiles p
JOIN auth.users au ON p.id = au.id;
SELECT 'Users VIEW created' as status;
-- ============================================================
-- STEP 7: Create the trigger for new users
-- ============================================================
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, account_type, onboarding_status, display_name)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'account_type', 'ghostwriter'),
'pending',
NEW.raw_user_meta_data->>'display_name'
)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
SELECT 'Trigger created' as status;
-- ============================================================
-- STEP 8: Add NEW FK constraints pointing to auth.users
-- ============================================================
-- Add FK to customers (user_id can be NULL)
ALTER TABLE customers
ADD CONSTRAINT customers_user_id_fkey
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
-- Add FK to companies (only if there are no orphaned references)
ALTER TABLE companies
ADD CONSTRAINT companies_owner_user_id_fkey
FOREIGN KEY (owner_user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
-- Add FK to invitations
ALTER TABLE invitations
ADD CONSTRAINT invitations_invited_by_user_id_fkey
FOREIGN KEY (invited_by_user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE invitations
ADD CONSTRAINT invitations_accepted_by_user_id_fkey
FOREIGN KEY (accepted_by_user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
-- Add FK from profiles to companies
ALTER TABLE profiles
ADD CONSTRAINT fk_profiles_company
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL;
-- Add FK from profiles to customers
ALTER TABLE profiles
ADD CONSTRAINT fk_profiles_customer
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE SET NULL;
SELECT 'New FK constraints added' as status;
-- ============================================================
-- STEP 9: Create indexes on profiles
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_profiles_account_type ON profiles(account_type);
CREATE INDEX IF NOT EXISTS idx_profiles_onboarding_status ON profiles(onboarding_status);
CREATE INDEX IF NOT EXISTS idx_profiles_customer_id ON profiles(customer_id);
CREATE INDEX IF NOT EXISTS idx_profiles_company_id ON profiles(company_id);
SELECT 'Indexes created' as status;
-- ============================================================
-- STEP 10: Create updated_at trigger for profiles
-- ============================================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_profiles_updated_at ON profiles;
CREATE TRIGGER update_profiles_updated_at
BEFORE UPDATE ON profiles
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
SELECT 'Updated_at trigger created' as status;
-- ============================================================
-- STEP 11: Enable RLS on profiles
-- ============================================================
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can view own profile" ON profiles;
DROP POLICY IF EXISTS "Users can update own profile" ON profiles;
DROP POLICY IF EXISTS "Service role has full access to profiles" ON profiles;
CREATE POLICY "Users can view own profile"
ON profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE
USING (auth.uid() = id);
CREATE POLICY "Service role has full access to profiles"
ON profiles FOR ALL
USING (true);
SELECT 'RLS enabled' as status;
-- ============================================================
-- VERIFICATION
-- ============================================================
SELECT 'Migration complete!' as status;
SELECT 'Profiles count:' as info, COUNT(*) as count FROM profiles;
SELECT 'Auth users count:' as info, COUNT(*) as count FROM auth.users;
SELECT 'Customers with user_id:' as info, COUNT(*) as count FROM customers WHERE user_id IS NOT NULL;
SELECT 'Companies count:' as info, COUNT(*) as count FROM companies;
-- ============================================================
-- CLEANUP (run later after verifying everything works)
-- ============================================================
DROP TABLE IF EXISTS users_old;
DROP TABLE IF EXISTS users_backup;
DROP TABLE IF EXISTS customers_backup;
DROP TABLE IF EXISTS companies_backup;
DROP TABLE IF EXISTS invitations_backup;

View File

@@ -1,30 +1,92 @@
-- LinkedIn Workflow Database Schema for Supabase -- LinkedIn Workflow Database Schema for Supabase
-- Post-migration schema: No customers table, all content uses user_id
-- Enable UUID extension -- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Customers/Clients Table -- ==================== GHOSTWRITER & COMPANY ACCOUNTS ====================
CREATE TABLE IF NOT EXISTS customers ( -- Uses Supabase Auth (auth.users) for authentication
id UUID PRIMARY wKEY DEFAULT uuid_generate_v4(), -- The profiles table stores application-specific user data
-- Profiles Table (extends auth.users with app-specific data)
CREATE TABLE IF NOT EXISTS profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Basic Info -- Account Type
name TEXT NOT NULL, account_type TEXT NOT NULL DEFAULT 'ghostwriter', -- 'ghostwriter' | 'company' | 'employee'
email TEXT,
company_name TEXT,
-- LinkedIn Profile -- Display name
linkedin_url TEXT NOT NULL UNIQUE, display_name TEXT,
-- Metadata -- Onboarding
metadata JSONB DEFAULT '{}'::JSONB onboarding_status TEXT DEFAULT 'pending', -- 'pending' | 'profile_setup' | 'posts_scraped' | 'categorizing' | 'completed'
onboarding_data JSONB DEFAULT '{}'::JSONB,
-- Company link
company_id UUID, -- FK added after companies table
-- Fields migrated from customers
linkedin_url TEXT,
writing_style_notes TEXT,
metadata JSONB DEFAULT '{}'::JSONB,
profile_picture TEXT,
creator_email TEXT,
customer_email TEXT,
is_active BOOLEAN DEFAULT TRUE
); );
-- Function to automatically create profile on user signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, account_type, onboarding_status)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'account_type', 'ghostwriter'),
'pending'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Trigger to create profile on auth.users insert
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- Companies Table (company accounts)
CREATE TABLE IF NOT EXISTS companies (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Company Data
name TEXT NOT NULL,
description TEXT,
website TEXT,
industry TEXT,
-- Strategy (used during post creation)
company_strategy JSONB DEFAULT '{}'::JSONB,
owner_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
onboarding_completed BOOLEAN DEFAULT FALSE
);
-- Add FK from profiles to companies (after companies table exists)
ALTER TABLE profiles
ADD CONSTRAINT fk_profiles_company
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL;
-- ==================== CONTENT TABLES (all use user_id) ====================
-- LinkedIn Profiles Table (scraped data) -- LinkedIn Profiles Table (scraped data)
CREATE TABLE IF NOT EXISTS linkedin_profiles ( CREATE TABLE IF NOT EXISTS linkedin_profiles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Profile Data -- Profile Data
@@ -37,123 +99,13 @@ CREATE TABLE IF NOT EXISTS linkedin_profiles (
location TEXT, location TEXT,
industry TEXT, industry TEXT,
UNIQUE(customer_id) UNIQUE(user_id)
); );
-- LinkedIn Posts Table (scraped posts)
CREATE TABLE IF NOT EXISTS linkedin_posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Post Data
post_url TEXT,
post_text TEXT NOT NULL,
post_date TIMESTAMP WITH TIME ZONE,
likes INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
shares INTEGER DEFAULT 0,
-- Raw Data
raw_data JSONB,
UNIQUE(customer_id, post_url)
);
-- Topics Table (extracted from posts)
CREATE TABLE IF NOT EXISTS topics (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Topic Info
title TEXT NOT NULL,
description TEXT,
category TEXT,
-- AI Extraction
extracted_from_post_id UUID REFERENCES linkedin_posts(id),
extraction_confidence FLOAT,
-- Status
is_used BOOLEAN DEFAULT FALSE,
used_at TIMESTAMP WITH TIME ZONE
);
-- Profile Analysis Table (AI-generated insights)
CREATE TABLE IF NOT EXISTS profile_analyses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Analysis Results
writing_style JSONB NOT NULL,
tone_analysis JSONB NOT NULL,
topic_patterns JSONB NOT NULL,
audience_insights JSONB NOT NULL,
-- Full Analysis
full_analysis JSONB NOT NULL,
UNIQUE(customer_id)
);
-- Research Results Table
CREATE TABLE IF NOT EXISTS research_results (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Research Data
query TEXT NOT NULL,
results JSONB NOT NULL,
-- Topic Suggestions
suggested_topics JSONB NOT NULL,
-- Metadata
source TEXT DEFAULT 'perplexity'
);
-- Generated Posts Table
CREATE TABLE IF NOT EXISTS generated_posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Topic
topic_id UUID REFERENCES topics(id),
topic_title TEXT NOT NULL,
-- Post Content
post_content TEXT NOT NULL,
-- Generation Metadata
iterations INTEGER DEFAULT 0,
writer_versions JSONB DEFAULT '[]'::JSONB,
critic_feedback JSONB DEFAULT '[]'::JSONB,
-- Status
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'approved', 'published', 'rejected')),
approved_at TIMESTAMP WITH TIME ZONE,
published_at TIMESTAMP WITH TIME ZONE
);
-- Create Indexes
CREATE INDEX idx_customers_linkedin_url ON customers(linkedin_url);
CREATE INDEX idx_linkedin_profiles_customer_id ON linkedin_profiles(customer_id);
CREATE INDEX idx_linkedin_posts_customer_id ON linkedin_posts(customer_id);
CREATE INDEX idx_topics_customer_id ON topics(customer_id);
CREATE INDEX idx_topics_is_used ON topics(is_used);
CREATE INDEX idx_profile_analyses_customer_id ON profile_analyses(customer_id);
CREATE INDEX idx_research_results_customer_id ON research_results(customer_id);
CREATE INDEX idx_generated_posts_customer_id ON generated_posts(customer_id);
CREATE INDEX idx_generated_posts_status ON generated_posts(status);
-- Post Types Table (for categorizing posts by type) -- Post Types Table (for categorizing posts by type)
CREATE TABLE IF NOT EXISTS post_types ( CREATE TABLE IF NOT EXISTS post_types (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
@@ -172,36 +124,228 @@ CREATE TABLE IF NOT EXISTS post_types (
-- Status -- Status
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
UNIQUE(customer_id, name) UNIQUE(user_id, name)
); );
-- Add post_type_id to linkedin_posts -- LinkedIn Posts Table (scraped posts)
ALTER TABLE linkedin_posts CREATE TABLE IF NOT EXISTS linkedin_posts (
ADD COLUMN IF NOT EXISTS post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL, id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ADD COLUMN IF NOT EXISTS classification_method TEXT, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS classification_confidence FLOAT; scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Add target_post_type_id to topics -- Post Data
ALTER TABLE topics post_url TEXT,
ADD COLUMN IF NOT EXISTS target_post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL; post_text TEXT NOT NULL,
post_date TIMESTAMP WITH TIME ZONE,
likes INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
shares INTEGER DEFAULT 0,
-- Add target_post_type_id to research_results -- Raw Data
ALTER TABLE research_results raw_data JSONB,
ADD COLUMN IF NOT EXISTS target_post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL;
-- Add post_type_id to generated_posts -- Classification
ALTER TABLE generated_posts post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL; classification_method TEXT,
classification_confidence FLOAT,
-- Create indexes for post_types UNIQUE(user_id, post_url)
CREATE INDEX IF NOT EXISTS idx_post_types_customer_id ON post_types(customer_id); );
CREATE INDEX IF NOT EXISTS idx_post_types_is_active ON post_types(is_active);
CREATE INDEX IF NOT EXISTS idx_linkedin_posts_post_type_id ON linkedin_posts(post_type_id); -- Topics Table (extracted from posts)
CREATE TABLE IF NOT EXISTS topics (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Topic Info
title TEXT NOT NULL,
description TEXT,
category TEXT,
-- AI Extraction
extracted_from_post_id UUID REFERENCES linkedin_posts(id),
extraction_confidence FLOAT,
-- Status
is_used BOOLEAN DEFAULT FALSE,
used_at TIMESTAMP WITH TIME ZONE,
-- Target post type
target_post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL
);
-- Profile Analysis Table (AI-generated insights)
CREATE TABLE IF NOT EXISTS profile_analyses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Analysis Results
writing_style JSONB NOT NULL,
tone_analysis JSONB NOT NULL,
topic_patterns JSONB NOT NULL,
audience_insights JSONB NOT NULL,
-- Full Analysis
full_analysis JSONB NOT NULL,
UNIQUE(user_id)
);
-- Research Results Table
CREATE TABLE IF NOT EXISTS research_results (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Research Data
query TEXT NOT NULL,
results JSONB NOT NULL,
-- Topic Suggestions
suggested_topics JSONB NOT NULL,
-- Metadata
source TEXT DEFAULT 'perplexity',
-- Target post type
target_post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL
);
-- Generated Posts Table
CREATE TABLE IF NOT EXISTS generated_posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Topic
topic_id UUID REFERENCES topics(id),
topic_title TEXT NOT NULL,
-- Post Content
post_content TEXT NOT NULL,
-- Generation Metadata
iterations INTEGER DEFAULT 0,
writer_versions JSONB DEFAULT '[]'::JSONB,
critic_feedback JSONB DEFAULT '[]'::JSONB,
-- Status
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'approved', 'scheduled', 'published', 'rejected')),
approved_at TIMESTAMP WITH TIME ZONE,
published_at TIMESTAMP WITH TIME ZONE,
-- Post type
post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL,
-- Scheduling
scheduled_at TIMESTAMP WITH TIME ZONE,
scheduled_by_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
-- Image
image_url TEXT
);
-- Invitations Table (employee invitations)
CREATE TABLE IF NOT EXISTS invitations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
email TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
invited_by_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
status TEXT DEFAULT 'pending', -- 'pending' | 'accepted' | 'expired' | 'cancelled'
accepted_at TIMESTAMP WITH TIME ZONE,
accepted_by_user_id UUID REFERENCES auth.users(id)
);
-- Example Posts Table (manual example posts when <10 scraped)
CREATE TABLE IF NOT EXISTS example_posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
post_text TEXT NOT NULL,
source TEXT DEFAULT 'manual', -- 'manual' | 'reference_profile'
source_linkedin_url TEXT,
post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL
);
-- Reference Profiles Table (alternative LinkedIn profiles for scraping)
CREATE TABLE IF NOT EXISTS reference_profiles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
linkedin_url TEXT NOT NULL,
name TEXT,
posts_scraped INTEGER DEFAULT 0,
UNIQUE(user_id, linkedin_url)
);
-- ==================== API USAGE LOGS ====================
CREATE TABLE IF NOT EXISTS api_usage_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
company_id UUID REFERENCES companies(id) ON DELETE SET NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
operation TEXT NOT NULL,
prompt_tokens INT NOT NULL DEFAULT 0,
completion_tokens INT NOT NULL DEFAULT 0,
total_tokens INT NOT NULL DEFAULT 0,
estimated_cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
-- ==================== INDEXES ====================
-- Profiles
CREATE INDEX IF NOT EXISTS idx_profiles_account_type ON profiles(account_type);
CREATE INDEX IF NOT EXISTS idx_profiles_onboarding_status ON profiles(onboarding_status);
CREATE INDEX IF NOT EXISTS idx_profiles_company_id ON profiles(company_id);
-- Content tables
CREATE INDEX IF NOT EXISTS idx_linkedin_profiles_user_id ON linkedin_profiles(user_id);
CREATE INDEX IF NOT EXISTS idx_linkedin_posts_user_id ON linkedin_posts(user_id);
CREATE INDEX IF NOT EXISTS idx_topics_user_id ON topics(user_id);
CREATE INDEX IF NOT EXISTS idx_topics_is_used ON topics(is_used);
CREATE INDEX IF NOT EXISTS idx_topics_target_post_type_id ON topics(target_post_type_id); CREATE INDEX IF NOT EXISTS idx_topics_target_post_type_id ON topics(target_post_type_id);
CREATE INDEX IF NOT EXISTS idx_post_types_user_id ON post_types(user_id);
CREATE INDEX IF NOT EXISTS idx_post_types_is_active ON post_types(is_active);
CREATE INDEX IF NOT EXISTS idx_profile_analyses_user_id ON profile_analyses(user_id);
CREATE INDEX IF NOT EXISTS idx_research_results_user_id ON research_results(user_id);
CREATE INDEX IF NOT EXISTS idx_research_results_target_post_type_id ON research_results(target_post_type_id); CREATE INDEX IF NOT EXISTS idx_research_results_target_post_type_id ON research_results(target_post_type_id);
CREATE INDEX IF NOT EXISTS idx_generated_posts_user_id ON generated_posts(user_id);
CREATE INDEX IF NOT EXISTS idx_generated_posts_status ON generated_posts(status);
CREATE INDEX IF NOT EXISTS idx_generated_posts_post_type_id ON generated_posts(post_type_id); CREATE INDEX IF NOT EXISTS idx_generated_posts_post_type_id ON generated_posts(post_type_id);
CREATE INDEX IF NOT EXISTS idx_generated_posts_scheduled_at ON generated_posts(scheduled_at)
WHERE scheduled_at IS NOT NULL AND status = 'scheduled';
CREATE INDEX IF NOT EXISTS idx_linkedin_posts_post_type_id ON linkedin_posts(post_type_id);
CREATE INDEX IF NOT EXISTS idx_example_posts_user_id ON example_posts(user_id);
CREATE INDEX IF NOT EXISTS idx_reference_profiles_user_id ON reference_profiles(user_id);
-- Companies & Invitations
CREATE INDEX IF NOT EXISTS idx_companies_owner_user_id ON companies(owner_user_id);
CREATE INDEX IF NOT EXISTS idx_invitations_token ON invitations(token);
CREATE INDEX IF NOT EXISTS idx_invitations_company_id ON invitations(company_id);
CREATE INDEX IF NOT EXISTS idx_invitations_email ON invitations(email);
CREATE INDEX IF NOT EXISTS idx_invitations_status ON invitations(status);
-- API usage
CREATE INDEX IF NOT EXISTS idx_api_usage_logs_user_id ON api_usage_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_api_usage_logs_company_id ON api_usage_logs(company_id);
CREATE INDEX IF NOT EXISTS idx_api_usage_logs_created_at ON api_usage_logs(created_at);
-- ==================== TRIGGERS ====================
-- Create updated_at trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column() CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
BEGIN BEGIN
@@ -210,15 +354,196 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- Add trigger to customers table DROP TRIGGER IF EXISTS update_profiles_updated_at ON profiles;
CREATE TRIGGER update_customers_updated_at CREATE TRIGGER update_profiles_updated_at
BEFORE UPDATE ON customers BEFORE UPDATE ON profiles
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column(); EXECUTE FUNCTION update_updated_at_column();
-- Add trigger to post_types table
DROP TRIGGER IF EXISTS update_post_types_updated_at ON post_types; DROP TRIGGER IF EXISTS update_post_types_updated_at ON post_types;
CREATE TRIGGER update_post_types_updated_at CREATE TRIGGER update_post_types_updated_at
BEFORE UPDATE ON post_types BEFORE UPDATE ON post_types
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column(); EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_companies_updated_at ON companies;
CREATE TRIGGER update_companies_updated_at
BEFORE UPDATE ON companies
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- ==================== ROW LEVEL SECURITY ====================
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own profile"
ON profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE
USING (auth.uid() = id);
CREATE POLICY "Service role has full access to profiles"
ON profiles
USING (auth.jwt() ->> 'role' = 'service_role');
ALTER TABLE companies ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Company owners can manage their company"
ON companies
USING (auth.uid() = owner_user_id);
CREATE POLICY "Employees can view their company"
ON companies FOR SELECT
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.company_id = companies.id
)
);
CREATE POLICY "Service role has full access to companies"
ON companies
USING (auth.jwt() ->> 'role' = 'service_role');
-- Generated posts RLS
ALTER TABLE generated_posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can manage own posts"
ON generated_posts FOR ALL
USING (user_id = auth.uid());
CREATE POLICY "Company members can view company posts"
ON generated_posts FOR SELECT
USING (
EXISTS (
SELECT 1 FROM profiles p1
JOIN profiles p2 ON p1.company_id = p2.company_id
WHERE p1.id = auth.uid()
AND p2.id = generated_posts.user_id
AND p1.company_id IS NOT NULL
)
);
CREATE POLICY "Service role has full access to generated_posts"
ON generated_posts FOR ALL
USING (auth.jwt() ->> 'role' = 'service_role');
-- ==================== USERS VIEW ====================
CREATE OR REPLACE VIEW users AS
SELECT
au.id,
au.created_at,
au.updated_at,
au.email,
au.raw_user_meta_data->>'sub' AS linkedin_sub,
au.raw_user_meta_data->>'vanityName' AS linkedin_vanity_name,
au.raw_user_meta_data->>'name' AS linkedin_name,
au.raw_user_meta_data->>'picture' AS linkedin_picture,
CASE
WHEN au.raw_user_meta_data->>'iss' LIKE '%linkedin%' THEN 'linkedin_oauth'
ELSE 'email_password'
END AS auth_method,
COALESCE(p.account_type, 'ghostwriter') AS account_type,
p.display_name,
COALESCE(p.onboarding_status, 'pending') AS onboarding_status,
COALESCE(p.onboarding_data, '{}'::jsonb) AS onboarding_data,
p.company_id,
p.linkedin_url,
p.writing_style_notes,
p.metadata,
p.profile_picture,
p.creator_email,
p.customer_email,
p.is_active,
au.email_confirmed_at IS NOT NULL AS email_verified
FROM auth.users au
LEFT JOIN profiles p ON au.id = p.id;
-- ==================== LICENSE KEY SYSTEM ====================
-- License Keys Table
CREATE TABLE IF NOT EXISTS license_keys (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
key TEXT UNIQUE NOT NULL,
description TEXT,
-- Limits (stored here, NOT in companies table)
max_employees INT NOT NULL DEFAULT 5,
max_posts_per_day INT NOT NULL DEFAULT 10,
max_researches_per_day INT NOT NULL DEFAULT 5,
-- Usage tracking
used BOOLEAN DEFAULT FALSE,
company_id UUID REFERENCES companies(id) ON DELETE SET NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Index for fast lookups
CREATE INDEX IF NOT EXISTS idx_license_keys_key ON license_keys(key);
CREATE INDEX IF NOT EXISTS idx_license_keys_used ON license_keys(used);
CREATE INDEX IF NOT EXISTS idx_license_keys_company_id ON license_keys(company_id);
-- RLS Policies (Admin only)
ALTER TABLE license_keys ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Service role has full access to license_keys"
ON license_keys FOR ALL
USING (auth.jwt()->>'role' = 'service_role');
-- Add license key reference to companies table
-- (limits are stored in license_keys table, not duplicated here)
ALTER TABLE companies ADD COLUMN IF NOT EXISTS license_key_id UUID REFERENCES license_keys(id);
-- Index
CREATE INDEX IF NOT EXISTS idx_companies_license_key_id ON companies(license_key_id);
-- Company Daily Quotas Table
CREATE TABLE IF NOT EXISTS company_daily_quotas (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
date DATE NOT NULL,
posts_created INT DEFAULT 0,
researches_created INT DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(company_id, date)
);
-- Index for fast daily lookups
CREATE INDEX IF NOT EXISTS idx_company_daily_quotas_company_date ON company_daily_quotas(company_id, date);
-- RLS Policies
ALTER TABLE company_daily_quotas ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own company quotas"
ON company_daily_quotas FOR SELECT
USING (
company_id IN (
SELECT company_id FROM profiles WHERE id = auth.uid()
)
);
CREATE POLICY "Service role has full access to company_daily_quotas"
ON company_daily_quotas FOR ALL
USING (auth.jwt()->>'role' = 'service_role');
-- Triggers for updated_at
DROP TRIGGER IF EXISTS update_license_keys_updated_at ON license_keys;
CREATE TRIGGER update_license_keys_updated_at
BEFORE UPDATE ON license_keys
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_company_daily_quotas_updated_at ON company_daily_quotas;
CREATE TRIGGER update_company_daily_quotas_updated_at
BEFORE UPDATE ON company_daily_quotas
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@@ -6,18 +6,19 @@ services:
container_name: linkedin-posts container_name: linkedin-posts
restart: unless-stopped restart: unless-stopped
expose: expose:
- "8000" - "8001"
env_file: env_file:
- .env - .env
environment: environment:
- PYTHONPATH=/app - PYTHONPATH=/app
- PORT=8001
- VIRTUAL_HOST=linkedin.onyva.dev - VIRTUAL_HOST=linkedin.onyva.dev
- VIRTUAL_PORT=8000 - VIRTUAL_PORT=8001
- LETSENCRYPT_HOST=linkedin.onyva.dev - LETSENCRYPT_HOST=linkedin.onyva.dev
volumes: volumes:
- ./logs:/app/logs - ./logs:/app/logs
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/login', timeout=5)"] test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8001/login', timeout=5)"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

View File

@@ -6,16 +6,17 @@ services:
container_name: linkedin-posts container_name: linkedin-posts
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8000:8000" - "8001:8001"
env_file: env_file:
- .env - .env
environment: environment:
- PYTHONPATH=/app - PYTHONPATH=/app
- PORT=8001
volumes: volumes:
# Optional: Mount logs directory # Optional: Mount logs directory
- ./logs:/app/logs - ./logs:/app/logs
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/login', timeout=5)"] test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8001/login', timeout=5)"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

694
localhost.har Normal file

File diff suppressed because one or more lines are too long

View File

@@ -18,6 +18,7 @@ rich==13.7.0
tenacity==8.2.3 tenacity==8.2.3
loguru==0.7.2 loguru==0.7.2
httpx==0.27.0 httpx==0.27.0
cryptography==41.0.7
# Web Frontend # Web Frontend
fastapi==0.115.0 fastapi==0.115.0

View File

@@ -0,0 +1,145 @@
"""Backfill api_usage_logs for all generated posts not yet tracked.
Assumes per post:
- 1x gpt-4o call: ~20,000 tokens (14,000 prompt + 6,000 completion)
- 1x gpt-4o-mini call: ~17,000 tokens (13,000 prompt + 4,000 completion)
Only processes posts older than 20 minutes whose created_at is not already
covered by an existing api_usage_log entry for the same customer.
"""
import asyncio
import sys
import os
from datetime import datetime, timedelta, timezone
from uuid import UUID
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.config import estimate_cost
from src.database.client import db
# ── Estimated token splits per post ──────────────────────────────
GPT4O_PROMPT = 14_000
GPT4O_COMP = 6_000
GPT4O_TOTAL = GPT4O_PROMPT + GPT4O_COMP # 20 000
MINI_PROMPT = 13_000
MINI_COMP = 4_000
MINI_TOTAL = MINI_PROMPT + MINI_COMP # 17 000
async def main():
cutoff = datetime.now(timezone.utc) - timedelta(minutes=20)
print(f"Cutoff: posts created before {cutoff.isoformat()}")
# ── 1. Load all generated posts ──────────────────────────────
customers = await db.list_customers()
print(f"Found {len(customers)} customers")
all_posts = []
for cust in customers:
posts = await db.get_generated_posts(cust.id)
all_posts.extend(posts)
print(f"Found {len(all_posts)} total generated posts")
# Filter to posts older than 20 min
eligible = [
p for p in all_posts
if p.created_at and p.created_at.replace(tzinfo=timezone.utc) < cutoff
]
print(f"{len(eligible)} posts older than 20 min")
# ── 2. Load existing logs to avoid duplicates ────────────────
try:
existing_logs = await asyncio.to_thread(
lambda: db.client.table("api_usage_logs")
.select("customer_id, created_at")
.eq("operation", "post_creation_backfill")
.execute()
)
already_logged = set()
for log in existing_logs.data:
key = (log.get("customer_id"), log.get("created_at", "")[:19])
already_logged.add(key)
print(f"{len(already_logged)} existing backfill entries found")
except Exception as e:
print(f"Could not read existing logs (table may be new): {e}")
already_logged = set()
# ── 3. Build customer → user_id / company_id map ─────────────
cust_map = {}
for cust in customers:
cust_map[str(cust.id)] = {
"user_id": str(cust.user_id) if cust.user_id else None,
"company_id": str(cust.company_id) if cust.company_id else None,
}
# ── 4. Insert two log rows per post ──────────────────────────
inserted = 0
skipped = 0
for post in eligible:
cid = str(post.customer_id)
ts = post.created_at.isoformat()[:19] if post.created_at else ""
key = (cid, ts)
if key in already_logged:
skipped += 1
continue
ids = cust_map.get(cid, {})
user_id = ids.get("user_id")
company_id = ids.get("company_id")
base = {
"customer_id": cid,
"operation": "post_creation_backfill",
"created_at": post.created_at.isoformat() if post.created_at else None,
}
if user_id:
base["user_id"] = user_id
if company_id:
base["company_id"] = company_id
# Row 1: gpt-4o
gpt4o_cost = estimate_cost("gpt-4o", GPT4O_PROMPT, GPT4O_COMP)
row_4o = {
**base,
"provider": "openai",
"model": "gpt-4o",
"prompt_tokens": GPT4O_PROMPT,
"completion_tokens": GPT4O_COMP,
"total_tokens": GPT4O_TOTAL,
"estimated_cost_usd": round(gpt4o_cost, 6),
}
# Row 2: gpt-4o-mini
mini_cost = estimate_cost("gpt-4o-mini", MINI_PROMPT, MINI_COMP)
row_mini = {
**base,
"provider": "openai",
"model": "gpt-4o-mini",
"prompt_tokens": MINI_PROMPT,
"completion_tokens": MINI_COMP,
"total_tokens": MINI_TOTAL,
"estimated_cost_usd": round(mini_cost, 6),
}
try:
await asyncio.to_thread(
lambda r1=row_4o, r2=row_mini: db.client.table("api_usage_logs")
.insert([r1, r2]).execute()
)
inserted += 2
name = post.topic_title[:40] if post.topic_title else "?"
print(f" + {name} (gpt-4o ${gpt4o_cost:.4f} + mini ${mini_cost:.4f})")
except Exception as e:
print(f" ! Error for post {post.id}: {e}")
print(f"\nDone: {inserted} log rows inserted, {skipped} posts skipped (already backfilled)")
print(f"Estimated totals per post: gpt-4o ${estimate_cost('gpt-4o', GPT4O_PROMPT, GPT4O_COMP):.4f} + mini ${estimate_cost('gpt-4o-mini', MINI_PROMPT, MINI_COMP):.4f}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""
Generate a Fernet encryption key for token encryption.
Usage:
python scripts/generate_encryption_key.py
Add the output to your .env file as ENCRYPTION_KEY.
"""
from cryptography.fernet import Fernet
if __name__ == "__main__":
key = Fernet.generate_key().decode()
print("=" * 70)
print("Generated Encryption Key:")
print("=" * 70)
print(key)
print("=" * 70)
print("\nAdd this to your .env file:")
print(f"ENCRYPTION_KEY={key}")
print("\n⚠️ Keep this key secure and never commit it to git!")

171
scripts/setup_linkedin_auth.py Executable file
View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
Script to configure LinkedIn (OIDC) authentication for self-hosted Supabase.
This script updates your .env file or docker-compose.yml with the necessary GoTrue environment variables.
"""
import os
import sys
from pathlib import Path
# ==================== CONFIGURATION ====================
# TODO: Fill in your LinkedIn credentials below
LINKEDIN_CLIENT_ID = "your-linkedin-client-id"
LINKEDIN_CLIENT_SECRET = "your-linkedin-client-secret"
# Your Supabase instance URL (where your self-hosted instance is accessible)
SUPABASE_URL = "https://your-supabase-domain.com" # e.g., https://supabase.example.com
# Path to your Supabase docker-compose directory (where .env or docker-compose.yml is located)
DOCKER_COMPOSE_DIR = "/path/to/your/supabase" # e.g., /home/user/supabase
# =======================================================
def validate_config():
"""Validate that all required configuration is set."""
errors = []
if LINKEDIN_CLIENT_ID.startswith("your-"):
errors.append("LINKEDIN_CLIENT_ID")
if LINKEDIN_CLIENT_SECRET.startswith("your-"):
errors.append("LINKEDIN_CLIENT_SECRET")
if SUPABASE_URL.startswith("https://your-"):
errors.append("SUPABASE_URL")
if DOCKER_COMPOSE_DIR.startswith("/path/to/"):
errors.append("DOCKER_COMPOSE_DIR")
if errors:
print("❌ Error: Please update the following configuration variables in this script:")
for var in errors:
print(f" - {var}")
sys.exit(1)
def find_env_file():
"""Find the .env file in the docker-compose directory."""
compose_dir = Path(DOCKER_COMPOSE_DIR)
if not compose_dir.exists():
print(f"❌ Error: Directory not found: {DOCKER_COMPOSE_DIR}")
sys.exit(1)
# Look for common .env file locations
env_files = [
compose_dir / ".env",
compose_dir / "docker" / ".env",
compose_dir / ".env.local",
]
for env_file in env_files:
if env_file.exists():
return env_file
return None
def update_env_file(env_file):
"""Update the .env file with LinkedIn OAuth configuration."""
# LinkedIn OAuth configuration for GoTrue
linkedin_config = f"""
# LinkedIn OAuth Configuration (added by setup script)
GOTRUE_EXTERNAL_LINKEDIN_ENABLED=true
GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID={LINKEDIN_CLIENT_ID}
GOTRUE_EXTERNAL_LINKEDIN_SECRET={LINKEDIN_CLIENT_SECRET}
GOTRUE_EXTERNAL_LINKEDIN_REDIRECT_URI={SUPABASE_URL}/auth/v1/callback
"""
# Read existing content
if env_file:
with open(env_file, 'r') as f:
content = f.read()
# Check if LinkedIn config already exists
if "GOTRUE_EXTERNAL_LINKEDIN_ENABLED" in content:
print(f"⚠️ LinkedIn OAuth configuration already exists in {env_file}")
print(" Please update it manually or remove the existing lines first.")
return False
# Backup original file
backup_file = env_file.with_suffix('.env.backup')
with open(backup_file, 'w') as f:
f.write(content)
print(f"📋 Backup created: {backup_file}")
# Append LinkedIn config
with open(env_file, 'a') as f:
f.write(linkedin_config)
print(f"✅ LinkedIn OAuth configuration added to {env_file}")
return True
return False
def create_env_snippet():
"""Create a snippet file if no .env file is found."""
snippet_file = Path(DOCKER_COMPOSE_DIR) / "linkedin_oauth_snippet.env"
linkedin_config = f"""# LinkedIn OAuth Configuration for GoTrue
# Add these lines to your .env file or docker-compose.yml environment section
GOTRUE_EXTERNAL_LINKEDIN_ENABLED=true
GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID={LINKEDIN_CLIENT_ID}
GOTRUE_EXTERNAL_LINKEDIN_SECRET={LINKEDIN_CLIENT_SECRET}
GOTRUE_EXTERNAL_LINKEDIN_REDIRECT_URI={SUPABASE_URL}/auth/v1/callback
"""
with open(snippet_file, 'w') as f:
f.write(linkedin_config)
print(f"📄 Created configuration snippet: {snippet_file}")
print(" Copy these variables to your .env file or docker-compose.yml")
return True
def print_next_steps():
"""Print instructions for completing the setup."""
print("\n" + "="*60)
print("✅ Configuration complete!")
print("="*60)
print("\nNext steps:\n")
print("1. Restart your Supabase services:")
print(f" cd {DOCKER_COMPOSE_DIR}")
print(" docker-compose down")
print(" docker-compose up -d")
print()
print("2. Add redirect URL in LinkedIn Developer Portal:")
print(f" {SUPABASE_URL}/auth/v1/callback")
print()
print("3. Test the authentication in your application")
print()
print("4. Check GoTrue logs for any errors:")
print(" docker-compose logs -f auth")
print()
def main():
"""Main function."""
print("🔧 Configuring LinkedIn OAuth for self-hosted Supabase")
print("="*60)
validate_config()
env_file = find_env_file()
if env_file:
print(f"📁 Found .env file: {env_file}")
if update_env_file(env_file):
print_next_steps()
else:
print("\n⚠️ Please update your configuration manually.")
else:
print("⚠️ No .env file found in the docker-compose directory")
create_env_snippet()
print_next_steps()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""
Test script to verify LinkedIn auto-posting setup.
Usage:
python scripts/test_linkedin_setup.py
"""
import asyncio
import sys
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config import settings
from src.database.client import db
async def main():
"""Run setup checks."""
print("=" * 70)
print("LinkedIn Auto-Posting Setup Verification")
print("=" * 70)
print()
checks_passed = 0
checks_total = 0
# Check 1: Environment variables
print("📋 Checking environment variables...")
checks_total += 4
if settings.linkedin_client_id:
print(" ✅ LINKEDIN_CLIENT_ID is set")
checks_passed += 1
else:
print(" ❌ LINKEDIN_CLIENT_ID is missing")
if settings.linkedin_client_secret:
print(" ✅ LINKEDIN_CLIENT_SECRET is set")
checks_passed += 1
else:
print(" ❌ LINKEDIN_CLIENT_SECRET is missing")
if settings.linkedin_redirect_uri:
print(" ✅ LINKEDIN_REDIRECT_URI is set")
checks_passed += 1
print(f" URI: {settings.linkedin_redirect_uri}")
else:
print(" ❌ LINKEDIN_REDIRECT_URI is missing")
if settings.encryption_key:
print(" ✅ ENCRYPTION_KEY is set")
checks_passed += 1
else:
print(" ❌ ENCRYPTION_KEY is missing")
print()
# Check 2: Encryption
print("🔐 Testing encryption...")
checks_total += 1
try:
from src.utils.encryption import encrypt_token, decrypt_token
test_token = "test_access_token_12345"
encrypted = encrypt_token(test_token)
decrypted = decrypt_token(encrypted)
if decrypted == test_token:
print(" ✅ Encryption/decryption working")
checks_passed += 1
else:
print(" ❌ Encryption/decryption mismatch")
except Exception as e:
print(f" ❌ Encryption error: {e}")
print()
# Check 3: Database table
print("💾 Checking database schema...")
checks_total += 1
try:
# Try to query the table (will fail if it doesn't exist)
result = await asyncio.to_thread(
lambda: db.client.table("linkedin_accounts").select("id").limit(0).execute()
)
print(" ✅ linkedin_accounts table exists")
checks_passed += 1
except Exception as e:
print(f" ❌ linkedin_accounts table not found: {e}")
print(" Run: psql $DATABASE_URL -f config/migrate_add_linkedin_accounts.sql")
print()
# Check 4: LinkedIn service
print("🔧 Checking LinkedIn service...")
checks_total += 1
try:
from src.services.linkedin_service import linkedin_service
print(" ✅ LinkedIn service initialized")
checks_passed += 1
except Exception as e:
print(f" ❌ LinkedIn service error: {e}")
print()
# Summary
print("=" * 70)
print(f"Summary: {checks_passed}/{checks_total} checks passed")
print("=" * 70)
if checks_passed == checks_total:
print("✅ All checks passed! LinkedIn auto-posting is ready.")
print("\nNext steps:")
print("1. Restart your application")
print("2. Log in as an employee")
print("3. Go to Settings and connect your LinkedIn account")
print("4. Schedule a test post")
return 0
else:
print("❌ Some checks failed. Please fix the issues above.")
print("\nSetup guide: See LINKEDIN_SETUP.md for detailed instructions")
return 1
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)

View File

@@ -1,12 +1,13 @@
"""Base agent class.""" """Base agent class."""
import asyncio import asyncio
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional, Tuple
from openai import OpenAI from openai import OpenAI
import httpx import httpx
from loguru import logger from loguru import logger
from src.config import settings from src.config import settings, estimate_cost
from src.database.client import db
class BaseAgent(ABC): class BaseAgent(ABC):
@@ -21,13 +22,59 @@ class BaseAgent(ABC):
""" """
self.name = name self.name = name
self.openai_client = OpenAI(api_key=settings.openai_api_key) self.openai_client = OpenAI(api_key=settings.openai_api_key)
self._usage_logs: List[Dict[str, Any]] = []
self._user_id: Optional[str] = None
self._company_id: Optional[str] = None
self._operation: str = "unknown"
logger.info(f"Initialized {name} agent") logger.info(f"Initialized {name} agent")
def set_tracking_context(
self,
operation: str,
user_id: Optional[str] = None,
company_id: Optional[str] = None
):
"""Set context for usage tracking."""
self._operation = operation
self._user_id = user_id
self._company_id = company_id
self._usage_logs = []
@abstractmethod @abstractmethod
async def process(self, *args, **kwargs) -> Any: async def process(self, *args, **kwargs) -> Any:
"""Process the agent's task.""" """Process the agent's task."""
pass pass
async def _log_usage(self, provider: str, model: str, prompt_tokens: int, completion_tokens: int, total_tokens: int):
"""Log API usage to database."""
cost = estimate_cost(model, prompt_tokens, completion_tokens)
usage = {
"provider": provider,
"model": model,
"operation": self._operation,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
"estimated_cost_usd": cost
}
self._usage_logs.append(usage)
try:
from uuid import UUID
await db.log_api_usage(
provider=provider,
model=model,
operation=self._operation,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
estimated_cost_usd=cost,
user_id=UUID(self._user_id) if self._user_id else None,
company_id=UUID(self._company_id) if self._company_id else None
)
except Exception as e:
logger.warning(f"Failed to log usage to DB: {e}")
async def call_openai( async def call_openai(
self, self,
system_prompt: str, system_prompt: str,
@@ -74,6 +121,16 @@ class BaseAgent(ABC):
result = response.choices[0].message.content result = response.choices[0].message.content
logger.debug(f"[{self.name}] Received response (length: {len(result)})") logger.debug(f"[{self.name}] Received response (length: {len(result)})")
# Track usage
if response.usage:
await self._log_usage(
provider="openai",
model=model,
prompt_tokens=response.usage.prompt_tokens,
completion_tokens=response.usage.completion_tokens,
total_tokens=response.usage.total_tokens
)
return result return result
async def call_perplexity( async def call_perplexity(
@@ -117,4 +174,15 @@ class BaseAgent(ABC):
content = result["choices"][0]["message"]["content"] content = result["choices"][0]["message"]["content"]
logger.debug(f"[{self.name}] Received Perplexity response (length: {len(content)})") logger.debug(f"[{self.name}] Received Perplexity response (length: {len(content)})")
# Track usage
usage = result.get("usage", {})
if usage:
await self._log_usage(
provider="perplexity",
model=model,
prompt_tokens=usage.get("prompt_tokens", 0),
completion_tokens=usage.get("completion_tokens", 0),
total_tokens=usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
)
return content return content

View File

@@ -1,7 +1,7 @@
"""Research agent using Perplexity.""" """Research agent using Perplexity."""
import json import json
import random import random
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import Dict, Any, List from typing import Dict, Any, List
from loguru import logger from loguru import logger
@@ -258,7 +258,7 @@ Gib deine Antwort im folgenden JSON-Format zurück:
pain_points_text = ", ".join(pain_points) if pain_points else "Allgemeine Business-Probleme" pain_points_text = ", ".join(pain_points) if pain_points else "Allgemeine Business-Probleme"
# Current date for time-specific searches # Current date for time-specific searches
today = datetime.now() today = datetime.now(timezone.utc)
date_str = today.strftime("%d. %B %Y") date_str = today.strftime("%d. %B %Y")
week_ago = (today - timedelta(days=7)).strftime("%d. %B %Y") week_ago = (today - timedelta(days=7)).strftime("%d. %B %Y")

View File

@@ -27,7 +27,10 @@ class WriterAgent(BaseAgent):
critic_result: Optional[Dict[str, Any]] = None, critic_result: Optional[Dict[str, Any]] = None,
learned_lessons: Optional[Dict[str, Any]] = None, learned_lessons: Optional[Dict[str, Any]] = None,
post_type: Any = None, post_type: Any = None,
post_type_analysis: Optional[Dict[str, Any]] = None post_type_analysis: Optional[Dict[str, Any]] = None,
user_thoughts: str = "",
selected_hook: str = "",
company_strategy: Optional[Dict[str, Any]] = None
) -> str: ) -> str:
""" """
Write a LinkedIn post. Write a LinkedIn post.
@@ -42,6 +45,7 @@ class WriterAgent(BaseAgent):
learned_lessons: Optional lessons learned from past critic feedback learned_lessons: Optional lessons learned from past critic feedback
post_type: Optional PostType object for type-specific writing post_type: Optional PostType object for type-specific writing
post_type_analysis: Optional analysis of the post type post_type_analysis: Optional analysis of the post type
company_strategy: Optional company strategy to consider during writing
Returns: Returns:
Written LinkedIn post Written LinkedIn post
@@ -58,12 +62,19 @@ class WriterAgent(BaseAgent):
critic_result=critic_result, critic_result=critic_result,
learned_lessons=learned_lessons, learned_lessons=learned_lessons,
post_type=post_type, post_type=post_type,
post_type_analysis=post_type_analysis post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy
) )
else: else:
logger.info(f"Writing initial post for topic: {topic.get('title', 'Unknown')}") logger.info(f"Writing initial post for topic: {topic.get('title', 'Unknown')}")
if post_type: if post_type:
logger.info(f"Using post type: {post_type.name}") logger.info(f"Using post type: {post_type.name}")
if user_thoughts:
logger.info(f"Including user thoughts ({len(user_thoughts)} chars)")
if selected_hook:
logger.info(f"Using selected hook: {selected_hook[:50]}...")
# Select example posts - use semantic matching if enabled # Select example posts - use semantic matching if enabled
selected_examples = self._select_example_posts(topic, example_posts, profile_analysis) selected_examples = self._select_example_posts(topic, example_posts, profile_analysis)
@@ -76,7 +87,10 @@ class WriterAgent(BaseAgent):
example_posts=selected_examples, example_posts=selected_examples,
learned_lessons=learned_lessons, learned_lessons=learned_lessons,
post_type=post_type, post_type=post_type,
post_type_analysis=post_type_analysis post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy
) )
else: else:
return await self._write_single_draft( return await self._write_single_draft(
@@ -85,7 +99,10 @@ class WriterAgent(BaseAgent):
example_posts=selected_examples, example_posts=selected_examples,
learned_lessons=learned_lessons, learned_lessons=learned_lessons,
post_type=post_type, post_type=post_type,
post_type_analysis=post_type_analysis post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy
) )
def _select_example_posts( def _select_example_posts(
@@ -164,10 +181,14 @@ class WriterAgent(BaseAgent):
# If we still don't have enough, fill with top scored # If we still don't have enough, fill with top scored
while len(selected) < 3 and len(selected) < len(example_posts): while len(selected) < 3 and len(selected) < len(example_posts):
found = False
for item in scored_posts: for item in scored_posts:
if item["post"] not in selected: if item["post"] not in selected:
selected.append(item["post"]) selected.append(item["post"])
found = True
break break
if not found:
break # no more unique posts available
logger.info(f"Selected {len(selected)} example posts via semantic matching") logger.info(f"Selected {len(selected)} example posts via semantic matching")
return selected return selected
@@ -212,7 +233,10 @@ class WriterAgent(BaseAgent):
example_posts: List[str], example_posts: List[str],
learned_lessons: Optional[Dict[str, Any]] = None, learned_lessons: Optional[Dict[str, Any]] = None,
post_type: Any = None, post_type: Any = None,
post_type_analysis: Optional[Dict[str, Any]] = None post_type_analysis: Optional[Dict[str, Any]] = None,
user_thoughts: str = "",
selected_hook: str = "",
company_strategy: Optional[Dict[str, Any]] = None
) -> str: ) -> str:
""" """
Generate multiple drafts and select the best one. Generate multiple drafts and select the best one.
@@ -224,6 +248,7 @@ class WriterAgent(BaseAgent):
learned_lessons: Lessons learned from past feedback learned_lessons: Lessons learned from past feedback
post_type: Optional PostType object post_type: Optional PostType object
post_type_analysis: Optional post type analysis post_type_analysis: Optional post type analysis
company_strategy: Optional company strategy to consider
Returns: Returns:
Best selected draft Best selected draft
@@ -231,7 +256,7 @@ class WriterAgent(BaseAgent):
num_drafts = min(max(settings.writer_multi_draft_count, 2), 5) # Clamp between 2-5 num_drafts = min(max(settings.writer_multi_draft_count, 2), 5) # Clamp between 2-5
logger.info(f"Generating {num_drafts} drafts for selection") logger.info(f"Generating {num_drafts} drafts for selection")
system_prompt = self._get_system_prompt(profile_analysis, example_posts, learned_lessons, post_type, post_type_analysis) system_prompt = self._get_system_prompt(profile_analysis, example_posts, learned_lessons, post_type, post_type_analysis, company_strategy)
# Generate drafts in parallel with different temperatures/approaches # Generate drafts in parallel with different temperatures/approaches
draft_configs = [ draft_configs = [
@@ -244,7 +269,7 @@ class WriterAgent(BaseAgent):
# Create draft tasks # Create draft tasks
async def generate_draft(config: Dict, draft_num: int) -> Dict[str, Any]: async def generate_draft(config: Dict, draft_num: int) -> Dict[str, Any]:
user_prompt = self._get_user_prompt_for_draft(topic, draft_num, config["approach"]) user_prompt = self._get_user_prompt_for_draft(topic, draft_num, config["approach"], user_thoughts, selected_hook)
try: try:
draft = await self.call_openai( draft = await self.call_openai(
system_prompt=system_prompt, system_prompt=system_prompt,
@@ -286,7 +311,9 @@ class WriterAgent(BaseAgent):
self, self,
topic: Dict[str, Any], topic: Dict[str, Any],
draft_num: int, draft_num: int,
approach: str approach: str,
user_thoughts: str = "",
selected_hook: str = ""
) -> str: ) -> str:
"""Get user prompt with slight variations for different drafts.""" """Get user prompt with slight variations for different drafts."""
# Different emphasis for each draft # Different emphasis for each draft
@@ -305,8 +332,11 @@ class WriterAgent(BaseAgent):
if topic.get('angle'): if topic.get('angle'):
angle_section = f"\n**ANGLE/PERSPEKTIVE:**\n{topic.get('angle')}\n" angle_section = f"\n**ANGLE/PERSPEKTIVE:**\n{topic.get('angle')}\n"
# User-selected hook takes priority
hook_section = "" hook_section = ""
if topic.get('hook_idea'): if selected_hook:
hook_section = f"\n**HOOK (VERWENDE DIESEN ALS EINSTIEG!):**\n\"{selected_hook}\"\n"
elif topic.get('hook_idea'):
hook_section = f"\n**HOOK-IDEE (als Inspiration):**\n\"{topic.get('hook_idea')}\"\n" hook_section = f"\n**HOOK-IDEE (als Inspiration):**\n\"{topic.get('hook_idea')}\"\n"
facts_section = "" facts_section = ""
@@ -318,6 +348,34 @@ class WriterAgent(BaseAgent):
if topic.get('why_this_person'): if topic.get('why_this_person'):
why_section = f"\n**WARUM DU DARÜBER SCHREIBEN SOLLTEST:**\n{topic.get('why_this_person')}\n" why_section = f"\n**WARUM DU DARÜBER SCHREIBEN SOLLTEST:**\n{topic.get('why_this_person')}\n"
# User thoughts section
thoughts_section = ""
if user_thoughts:
thoughts_section = f"""
**PERSÖNLICHE GEDANKEN (UNBEDINGT einbauen!):**
{user_thoughts}
Diese Gedanken MÜSSEN im Post verarbeitet werden!
"""
# Adjust task based on hook
if selected_hook:
task_text = """**AUFGABE:**
Schreibe einen authentischen LinkedIn-Post, der:
1. Mit dem VORGEGEBENEN HOOK beginnt (exakt oder leicht angepasst)
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
3. Die persönlichen Gedanken organisch einbaut
4. Die Key Facts verwendet wo es passt
5. Mit einem passenden CTA endet"""
else:
task_text = """**AUFGABE:**
Schreibe einen authentischen LinkedIn-Post, der:
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze die Hook-Idee als Inspiration, NICHT wörtlich!)
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
3. Die persönlichen Gedanken organisch einbaut (falls vorhanden)
4. Die Key Facts einbaut wo es passt
5. Mit einem passenden CTA endet"""
return f"""Schreibe einen LinkedIn-Post zu folgendem Thema: return f"""Schreibe einen LinkedIn-Post zu folgendem Thema:
**THEMA:** {topic.get('title', 'Unbekanntes Thema')} **THEMA:** {topic.get('title', 'Unbekanntes Thema')}
@@ -326,26 +384,19 @@ class WriterAgent(BaseAgent):
{angle_section}{hook_section} {angle_section}{hook_section}
**KERN-FAKT / INHALT:** **KERN-FAKT / INHALT:**
{topic.get('fact', topic.get('description', ''))} {topic.get('fact', topic.get('description', ''))}
{facts_section} {facts_section}{thoughts_section}
**WARUM RELEVANT:** **WARUM RELEVANT:**
{topic.get('relevance', 'Aktuelles Thema für die Zielgruppe')} {topic.get('relevance', 'Aktuelles Thema für die Zielgruppe')}
{why_section} {why_section}
**DEIN ANSATZ FÜR DIESEN ENTWURF ({approach}):** **DEIN ANSATZ FÜR DIESEN ENTWURF ({approach}):**
{emphasis} {emphasis}
**AUFGABE:** {task_text}
Schreibe einen authentischen LinkedIn-Post, der:
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze die Hook-Idee als Inspiration, NICHT wörtlich!)
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
3. Die Key Facts einbaut wo es passt
4. Eine persönliche Note oder Meinung enthält
5. Mit einem passenden CTA endet
WICHTIG: WICHTIG:
- Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.) - Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.)
- Schreibe natürlich und menschlich - Schreibe natürlich und menschlich
- Der Post soll SOFORT 85+ Punkte im Review erreichen - Der Post soll SOFORT 85+ Punkte im Review erreichen
- Die Hook-Idee ist nur INSPIRATION - mach etwas Eigenes daraus!
Gib NUR den fertigen Post zurück.""" Gib NUR den fertigen Post zurück."""
@@ -446,7 +497,10 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
critic_result: Optional[Dict[str, Any]] = None, critic_result: Optional[Dict[str, Any]] = None,
learned_lessons: Optional[Dict[str, Any]] = None, learned_lessons: Optional[Dict[str, Any]] = None,
post_type: Any = None, post_type: Any = None,
post_type_analysis: Optional[Dict[str, Any]] = None post_type_analysis: Optional[Dict[str, Any]] = None,
user_thoughts: str = "",
selected_hook: str = "",
company_strategy: Optional[Dict[str, Any]] = None
) -> str: ) -> str:
"""Write a single draft (original behavior).""" """Write a single draft (original behavior)."""
# Select examples if not already selected # Select examples if not already selected
@@ -461,8 +515,8 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
elif len(selected_examples) > 3: elif len(selected_examples) > 3:
selected_examples = random.sample(selected_examples, 3) selected_examples = random.sample(selected_examples, 3)
system_prompt = self._get_system_prompt(profile_analysis, selected_examples, learned_lessons, post_type, post_type_analysis) system_prompt = self._get_system_prompt(profile_analysis, selected_examples, learned_lessons, post_type, post_type_analysis, company_strategy)
user_prompt = self._get_user_prompt(topic, feedback, previous_version, critic_result) user_prompt = self._get_user_prompt(topic, feedback, previous_version, critic_result, user_thoughts, selected_hook)
# Lower temperature for more consistent style matching # Lower temperature for more consistent style matching
post = await self.call_openai( post = await self.call_openai(
@@ -481,7 +535,8 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
example_posts: List[str] = None, example_posts: List[str] = None,
learned_lessons: Optional[Dict[str, Any]] = None, learned_lessons: Optional[Dict[str, Any]] = None,
post_type: Any = None, post_type: Any = None,
post_type_analysis: Optional[Dict[str, Any]] = None post_type_analysis: Optional[Dict[str, Any]] = None,
company_strategy: Optional[Dict[str, Any]] = None
) -> str: ) -> str:
"""Get system prompt for writer - orientiert an bewährten n8n-Prompts.""" """Get system prompt for writer - orientiert an bewährten n8n-Prompts."""
# Extract key profile information # Extract key profile information
@@ -670,16 +725,65 @@ Vermeide IMMER diese KI-typischen Muster:
- Zu perfekte, glatte Formulierungen - echte Menschen schreiben mit Ecken und Kanten - Zu perfekte, glatte Formulierungen - echte Menschen schreiben mit Ecken und Kanten
{lessons_section} {lessons_section}
{post_type_section} {post_type_section}
{self._get_company_strategy_section(company_strategy)}
DEIN AUFTRAG: Schreibe den Post so, dass er für die Zielgruppe ({audience.get('target_audience', 'Professionals')}) einen klaren Mehrwert bietet und ihre Pain Points ({pain_points_str}) adressiert. Mach die Persönlichkeit des linguistischen Fingerabdrucks spürbar. DEIN AUFTRAG: Schreibe den Post so, dass er für die Zielgruppe ({audience.get('target_audience', 'Professionals')}) einen klaren Mehrwert bietet und ihre Pain Points ({pain_points_str}) adressiert. Mach die Persönlichkeit des linguistischen Fingerabdrucks spürbar.
Beginne DIREKT mit dem Hook. Keine einleitenden Sätze, kein "Hier ist der Post".""" Beginne DIREKT mit dem Hook. Keine einleitenden Sätze, kein "Hier ist der Post"."""
def _get_company_strategy_section(self, company_strategy: Optional[Dict[str, Any]] = None) -> str:
"""Build the company strategy section for the system prompt."""
if not company_strategy:
return ""
# Extract strategy elements
mission = company_strategy.get("mission", "")
vision = company_strategy.get("vision", "")
brand_voice = company_strategy.get("brand_voice", "")
tone_guidelines = company_strategy.get("tone_guidelines", "")
content_pillars = company_strategy.get("content_pillars", [])
target_audience = company_strategy.get("target_audience", "")
dos = company_strategy.get("dos", [])
donts = company_strategy.get("donts", [])
# Build section only if there's meaningful content
if not any([mission, vision, brand_voice, content_pillars, dos, donts]):
return ""
section = """
8. UNTERNEHMENSSTRATEGIE (WICHTIG - IMMER BEACHTEN!):
Der Post muss zur Unternehmensstrategie passen, aber authentisch im Stil des Autors bleiben!
"""
if mission:
section += f"\nMISSION: {mission}"
if vision:
section += f"\nVISION: {vision}"
if brand_voice:
section += f"\nBRAND VOICE: {brand_voice}"
if tone_guidelines:
section += f"\nTONE GUIDELINES: {tone_guidelines}"
if content_pillars:
section += f"\nCONTENT PILLARS: {', '.join(content_pillars)}"
if target_audience:
section += f"\nZIELGRUPPE: {target_audience}"
if dos:
section += f"\n\nDO's (empfohlen):\n" + "\n".join([f" + {d}" for d in dos])
if donts:
section += f"\n\nDON'Ts (vermeiden):\n" + "\n".join([f" - {d}" for d in donts])
section += "\n\nWICHTIG: Integriere die Unternehmenswerte subtil - der Post soll nicht wie eine Werbung klingen!"
return section
def _get_user_prompt( def _get_user_prompt(
self, self,
topic: Dict[str, Any], topic: Dict[str, Any],
feedback: Optional[str] = None, feedback: Optional[str] = None,
previous_version: Optional[str] = None, previous_version: Optional[str] = None,
critic_result: Optional[Dict[str, Any]] = None critic_result: Optional[Dict[str, Any]] = None,
user_thoughts: str = "",
selected_hook: str = ""
) -> str: ) -> str:
"""Get user prompt for writer.""" """Get user prompt for writer."""
if feedback and previous_version: if feedback and previous_version:
@@ -727,8 +831,11 @@ Gib NUR den überarbeiteten Post zurück - keine Kommentare."""
if topic.get('angle'): if topic.get('angle'):
angle_section = f"\n**ANGLE/PERSPEKTIVE:**\n{topic.get('angle')}\n" angle_section = f"\n**ANGLE/PERSPEKTIVE:**\n{topic.get('angle')}\n"
# User-selected hook takes priority over topic hook_idea
hook_section = "" hook_section = ""
if topic.get('hook_idea'): if selected_hook:
hook_section = f"\n**HOOK (VERWENDE DIESEN ALS EINSTIEG!):**\n\"{selected_hook}\"\n"
elif topic.get('hook_idea'):
hook_section = f"\n**HOOK-IDEE (als Inspiration):**\n\"{topic.get('hook_idea')}\"\n" hook_section = f"\n**HOOK-IDEE (als Inspiration):**\n\"{topic.get('hook_idea')}\"\n"
facts_section = "" facts_section = ""
@@ -736,6 +843,34 @@ Gib NUR den überarbeiteten Post zurück - keine Kommentare."""
if key_facts and isinstance(key_facts, list) and len(key_facts) > 0: if key_facts and isinstance(key_facts, list) and len(key_facts) > 0:
facts_section = "\n**KEY FACTS (nutze diese!):**\n" + "\n".join([f"- {f}" for f in key_facts]) + "\n" facts_section = "\n**KEY FACTS (nutze diese!):**\n" + "\n".join([f"- {f}" for f in key_facts]) + "\n"
# User thoughts section
thoughts_section = ""
if user_thoughts:
thoughts_section = f"""
**PERSÖNLICHE GEDANKEN (UNBEDINGT einbauen!):**
{user_thoughts}
Diese Gedanken MÜSSEN im Post verarbeitet werden - sie spiegeln die echte Meinung der Person wider!
"""
# Adjust task based on whether hook is provided
if selected_hook:
task_section = """**AUFGABE:**
Schreibe einen authentischen LinkedIn-Post, der:
1. Mit dem VORGEGEBENEN HOOK beginnt (exakt oder leicht angepasst)
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
3. Die persönlichen Gedanken organisch einbaut
4. Die Key Facts verwendet wo es passt
5. Mit einem passenden CTA endet"""
else:
task_section = """**AUFGABE:**
Schreibe einen authentischen LinkedIn-Post, der:
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze Hook-Idee als Inspiration!)
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
3. Die persönlichen Gedanken organisch einbaut (falls vorhanden)
4. Die Key Facts verwendet wo es passt
5. Mit einem passenden CTA endet"""
return f"""Schreibe einen LinkedIn-Post zu folgendem Thema: return f"""Schreibe einen LinkedIn-Post zu folgendem Thema:
**THEMA:** {topic.get('title', 'Unbekanntes Thema')} **THEMA:** {topic.get('title', 'Unbekanntes Thema')}
@@ -744,17 +879,11 @@ Gib NUR den überarbeiteten Post zurück - keine Kommentare."""
{angle_section}{hook_section} {angle_section}{hook_section}
**KERN-FAKT / INHALT:** **KERN-FAKT / INHALT:**
{topic.get('fact', topic.get('description', ''))} {topic.get('fact', topic.get('description', ''))}
{facts_section} {facts_section}{thoughts_section}
**WARUM RELEVANT:** **WARUM RELEVANT:**
{topic.get('relevance', 'Aktuelles Thema für die Zielgruppe')} {topic.get('relevance', 'Aktuelles Thema für die Zielgruppe')}
**AUFGABE:** {task_section}
Schreibe einen authentischen LinkedIn-Post, der:
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze Hook-Idee als Inspiration!)
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
3. Die Key Facts einbaut wo es passt
4. Eine persönliche Note oder Meinung enthält
5. Mit einem passenden CTA endet
WICHTIG: WICHTIG:
- Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.) - Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.)
@@ -762,3 +891,263 @@ WICHTIG:
- Der Post soll SOFORT 85+ Punkte im Review erreichen - Der Post soll SOFORT 85+ Punkte im Review erreichen
Gib NUR den fertigen Post zurück.""" Gib NUR den fertigen Post zurück."""
async def generate_hooks(
self,
topic: Dict[str, Any],
profile_analysis: Dict[str, Any],
user_thoughts: str = "",
post_type: Any = None
) -> List[Dict[str, str]]:
"""
Generate 4 different hook options for a topic.
Args:
topic: Topic dictionary with title, fact, etc.
profile_analysis: Profile analysis for style matching
user_thoughts: User's personal thoughts about the topic
post_type: Optional PostType object
Returns:
List of {"hook": "...", "style": "..."} dictionaries
"""
logger.info(f"Generating hooks for topic: {topic.get('title', 'Unknown')}")
# Extract style info from profile
tone = profile_analysis.get('tone_analysis', {}).get('primary_tone', 'Professionell')
energy = profile_analysis.get('linguistic_fingerprint', {}).get('energy_level', 7)
address = profile_analysis.get('writing_style', {}).get('form_of_address', 'Du')
hook_phrases = profile_analysis.get('phrase_library', {}).get('hook_phrases', [])
# Build example hooks section
example_hooks = ""
if hook_phrases:
example_hooks = "\n\nBeispiel-Hooks dieser Person (zur Inspiration):\n" + "\n".join([f"- {h}" for h in hook_phrases[:5]])
system_prompt = f"""Du bist ein Hook-Spezialist für LinkedIn Posts. Deine Aufgabe ist es, 4 verschiedene, aufmerksamkeitsstarke Hooks zu generieren.
STIL DER PERSON:
- Tonalität: {tone}
- Energie-Level: {energy}/10 (höher = emotionaler, niedriger = sachlicher)
- Ansprache: {address}
{example_hooks}
GENERIERE 4 VERSCHIEDENE HOOKS:
1. **Provokant** - Eine kontroverse These oder überraschende Aussage
2. **Storytelling** - Beginn einer persönlichen Geschichte oder Anekdote
3. **Fakten-basiert** - Eine überraschende Statistik oder Fakt
4. **Neugier-weckend** - Eine Frage oder ein Cliffhanger
REGELN:
- Jeder Hook sollte 1-2 Sätze lang sein
- Hooks müssen zum Energie-Level und Ton der Person passen
- Keine KI-typischen Phrasen ("In der heutigen Zeit", "Stellen Sie sich vor")
- Die Hooks sollen SOFORT Aufmerksamkeit erregen
- Bei Energie 8+ darf es emotional/leidenschaftlich sein
- Bei Energie 5-7 eher sachlich-professionell
Antworte im JSON-Format:
{{"hooks": [
{{"hook": "Der Hook-Text hier", "style": "Provokant"}},
{{"hook": "Der Hook-Text hier", "style": "Storytelling"}},
{{"hook": "Der Hook-Text hier", "style": "Fakten-basiert"}},
{{"hook": "Der Hook-Text hier", "style": "Neugier-weckend"}}
]}}"""
# Build user prompt
thoughts_section = ""
if user_thoughts:
thoughts_section = f"\n\nPERSÖNLICHE GEDANKEN DER PERSON ZUM THEMA:\n{user_thoughts}\n\nIntegriere diese Perspektive in die Hooks!"
post_type_section = ""
if post_type:
post_type_section = f"\n\nPOST-TYP: {post_type.name}"
if post_type.description:
post_type_section += f"\n{post_type.description}"
user_prompt = f"""Generiere 4 Hooks für dieses Thema:
THEMA: {topic.get('title', 'Unbekanntes Thema')}
KATEGORIE: {topic.get('category', 'Allgemein')}
KERN-FAKT/INHALT:
{topic.get('fact', topic.get('description', 'Keine Details verfügbar'))}
{thoughts_section}{post_type_section}
Generiere jetzt die 4 verschiedenen Hooks im JSON-Format."""
response = await self.call_openai(
system_prompt=system_prompt,
user_prompt=user_prompt,
model="gpt-4o-mini",
temperature=0.8,
response_format={"type": "json_object"}
)
try:
result = json.loads(response)
hooks = result.get("hooks", [])
logger.info(f"Generated {len(hooks)} hooks successfully")
return hooks
except (json.JSONDecodeError, KeyError) as e:
logger.error(f"Failed to parse hooks response: {e}")
# Return fallback hooks
return [
{"hook": f"Was wäre, wenn {topic.get('title', 'dieses Thema')} alles verändert?", "style": "Neugier-weckend"},
{"hook": f"Letzte Woche habe ich etwas über {topic.get('title', 'dieses Thema')} gelernt, das mich nicht mehr loslässt.", "style": "Storytelling"},
{"hook": f"Die meisten unterschätzen {topic.get('title', 'dieses Thema')}. Ein Fehler.", "style": "Provokant"},
{"hook": f"Eine Zahl hat mich diese Woche überrascht: {topic.get('fact', 'Ein überraschender Fakt')}.", "style": "Fakten-basiert"}
]
async def generate_improvement_suggestions(
self,
post_content: str,
profile_analysis: Dict[str, Any],
critic_feedback: Optional[Dict[str, Any]] = None
) -> List[Dict[str, str]]:
"""
Generate improvement suggestions for an existing post based on critic feedback.
Args:
post_content: The current post content
profile_analysis: Profile analysis for style matching
critic_feedback: Optional feedback from the critic with improvements list
Returns:
List of {"label": "...", "action": "..."} dictionaries
"""
logger.info("Generating improvement suggestions for post")
# Extract style info from profile
tone = profile_analysis.get('tone_analysis', {}).get('primary_tone', 'Professionell')
energy = profile_analysis.get('linguistic_fingerprint', {}).get('energy_level', 7)
address = profile_analysis.get('writing_style', {}).get('form_of_address', 'Du')
# Build feedback context
feedback_context = ""
if critic_feedback:
feedback_text = critic_feedback.get('feedback', '')
improvements = critic_feedback.get('improvements', [])
strengths = critic_feedback.get('strengths', [])
score = critic_feedback.get('overall_score', 0)
feedback_context = f"""
LETZTES KRITIKER-FEEDBACK (Score: {score}/100):
{feedback_text}
IDENTIFIZIERTE VERBESSERUNGSPUNKTE:
{chr(10).join(['- ' + imp for imp in improvements]) if improvements else '- Keine spezifischen Verbesserungen genannt'}
STÄRKEN (diese beibehalten!):
{chr(10).join(['- ' + s for s in strengths]) if strengths else '- Keine spezifischen Stärken genannt'}
"""
system_prompt = f"""Du generierst kurze, klickbare Verbesserungsvorschläge für LinkedIn-Posts.
PROFIL DER PERSON:
- Tonalität: {tone}
- Energie: {energy}/10
- Ansprache: {address}
{feedback_context}
DEINE AUFGABE:
Generiere 4 KURZE Verbesserungsvorschläge (max 5-6 Worte pro Label).
Jeder Vorschlag muss:
1. Auf dem Kritiker-Feedback basieren (falls vorhanden)
2. Zum Stil der Person passen
3. Eine konkrete, umsetzbare Änderung beschreiben
FORMAT - Kurze Labels wie:
- "Hook emotionaler machen"
- "Persönliche Anekdote einbauen"
- "Absätze kürzen"
- "Call-to-Action verstärken"
Antworte im JSON-Format:
{{"suggestions": [
{{"label": "Kurzes Label (max 6 Worte)", "action": "Detaillierte Anweisung für die KI, was genau geändert werden soll"}},
...
]}}"""
user_prompt = f"""POST:
{post_content[:1500]}
Generiere 4 kurze, spezifische Verbesserungsvorschläge basierend auf dem Feedback und Profil."""
response = await self.call_openai(
system_prompt=system_prompt,
user_prompt=user_prompt,
model="gpt-4o-mini",
temperature=0.7,
response_format={"type": "json_object"}
)
try:
result = json.loads(response)
suggestions = result.get("suggestions", [])
logger.info(f"Generated {len(suggestions)} improvement suggestions")
return suggestions
except (json.JSONDecodeError, KeyError) as e:
logger.error(f"Failed to parse suggestions response: {e}")
return [
{"label": "Hook verstärken", "action": "Mache den ersten Satz emotionaler und aufmerksamkeitsstärker"},
{"label": "Storytelling einbauen", "action": "Füge eine kurze persönliche Anekdote oder Erfahrung hinzu"},
{"label": "Call-to-Action ergänzen", "action": "Füge am Ende eine Frage oder Handlungsaufforderung hinzu"},
{"label": "Struktur verbessern", "action": "Kürze lange Absätze und verbessere die Lesbarkeit"}
]
async def apply_suggestion(
self,
post_content: str,
suggestion: str,
profile_analysis: Dict[str, Any]
) -> str:
"""
Apply a specific improvement suggestion to a post.
Args:
post_content: The current post content
suggestion: The suggestion to apply
profile_analysis: Profile analysis for style matching
Returns:
The improved post content
"""
logger.info(f"Applying suggestion to post: {suggestion[:50]}...")
# Extract style info from profile
tone = profile_analysis.get('tone_analysis', {}).get('primary_tone', 'Professionell')
energy = profile_analysis.get('linguistic_fingerprint', {}).get('energy_level', 7)
address = profile_analysis.get('writing_style', {}).get('form_of_address', 'Du')
system_prompt = f"""Du bist ein LinkedIn-Experte. Deine Aufgabe ist es, einen Post basierend auf einem konkreten Verbesserungsvorschlag zu überarbeiten.
STIL DER PERSON:
- Tonalität: {tone}
- Energie-Level: {energy}/10
- Ansprache: {address}
WICHTIG:
- Behalte den Kern und die Hauptaussage des Posts bei
- Wende NUR die angegebene Verbesserung an
- Behalte die Länge ungefähr bei
- Antworte NUR mit dem überarbeiteten Post, keine Erklärungen"""
user_prompt = f"""ORIGINAL-POST:
{post_content}
ANZUWENDENDE VERBESSERUNG:
{suggestion}
Schreibe jetzt den überarbeiteten Post:"""
response = await self.call_openai(
system_prompt=system_prompt,
user_prompt=user_prompt,
model="gpt-4o-mini",
temperature=0.5
)
logger.info("Successfully applied suggestion to post")
return response.strip()

View File

@@ -6,7 +6,6 @@ from pathlib import Path
class Settings(BaseSettings): class Settings(BaseSettings):
"""Application settings loaded from environment variables.""" """Application settings loaded from environment variables."""
# API Keys # API Keys
openai_api_key: str openai_api_key: str
perplexity_api_key: str perplexity_api_key: str
@@ -15,6 +14,7 @@ class Settings(BaseSettings):
# Supabase # Supabase
supabase_url: str supabase_url: str
supabase_key: str supabase_key: str
supabase_service_role_key: str = "" # Required for admin operations like deleting users
# Apify # Apify
apify_actor_id: str = "apimaestro~linkedin-profile-posts" apify_actor_id: str = "apimaestro~linkedin-profile-posts"
@@ -46,6 +46,14 @@ class Settings(BaseSettings):
user_frontend_enabled: bool = True # Enable user frontend with LinkedIn OAuth user_frontend_enabled: bool = True # Enable user frontend with LinkedIn OAuth
supabase_redirect_url: str = "" # OAuth Callback URL (e.g., https://linkedin.onyva.dev/auth/callback) supabase_redirect_url: str = "" # OAuth Callback URL (e.g., https://linkedin.onyva.dev/auth/callback)
# LinkedIn API (Custom OAuth for auto-posting)
linkedin_client_id: str = ""
linkedin_client_secret: str = ""
linkedin_redirect_uri: str = "" # e.g., https://yourdomain.com/settings/linkedin/callback
# Token Encryption
encryption_key: str = "" # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",
@@ -55,3 +63,18 @@ class Settings(BaseSettings):
# Global settings instance # Global settings instance
settings = Settings() settings = Settings()
# API pricing per 1M tokens (input, output)
API_PRICING = {
"gpt-4o": {"input": 2.50, "output": 10.00},
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
"sonar": {"input": 1.00, "output": 1.00},
}
def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
"""Estimate cost in USD for an API call."""
pricing = API_PRICING.get(model, {"input": 1.00, "output": 1.00})
input_cost = (prompt_tokens / 1_000_000) * pricing["input"]
output_cost = (completion_tokens / 1_000_000) * pricing["output"]
return input_cost + output_cost

View File

@@ -1,7 +1,6 @@
"""Database module.""" """Database module."""
from src.database.client import DatabaseClient, db from src.database.client import DatabaseClient, db
from src.database.models import ( from src.database.models import (
Customer,
LinkedInProfile, LinkedInProfile,
LinkedInPost, LinkedInPost,
Topic, Topic,
@@ -9,12 +8,24 @@ from src.database.models import (
ResearchResult, ResearchResult,
GeneratedPost, GeneratedPost,
PostType, PostType,
User,
Profile,
Company,
Invitation,
ExamplePost,
ReferenceProfile,
AccountType,
OnboardingStatus,
AuthMethod,
InvitationStatus,
ApiUsageLog,
LicenseKey,
CompanyDailyQuota,
) )
__all__ = [ __all__ = [
"DatabaseClient", "DatabaseClient",
"db", "db",
"Customer",
"LinkedInProfile", "LinkedInProfile",
"LinkedInPost", "LinkedInPost",
"Topic", "Topic",
@@ -22,4 +33,17 @@ __all__ = [
"ResearchResult", "ResearchResult",
"GeneratedPost", "GeneratedPost",
"PostType", "PostType",
"User",
"Profile",
"Company",
"Invitation",
"ExamplePost",
"ReferenceProfile",
"AccountType",
"OnboardingStatus",
"AuthMethod",
"InvitationStatus",
"ApiUsageLog",
"LicenseKey",
"CompanyDailyQuota",
] ]

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
"""Pydantic models for database entities.""" """Pydantic models for database entities."""
from datetime import datetime from datetime import datetime, date
from enum import Enum
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
@@ -10,22 +11,210 @@ class DBModel(BaseModel):
model_config = ConfigDict(extra='ignore') model_config = ConfigDict(extra='ignore')
class Customer(DBModel): # ==================== ENUMS ====================
"""Customer/Client model."""
class AccountType(str, Enum):
"""User account type."""
GHOSTWRITER = "ghostwriter"
COMPANY = "company"
EMPLOYEE = "employee"
class OnboardingStatus(str, Enum):
"""Onboarding status for users."""
PENDING = "pending"
PROFILE_SETUP = "profile_setup"
POSTS_SCRAPED = "posts_scraped"
CATEGORIZING = "categorizing"
COMPLETED = "completed"
class AuthMethod(str, Enum):
"""Authentication method for users."""
LINKEDIN_OAUTH = "linkedin_oauth"
EMAIL_PASSWORD = "email_password"
class InvitationStatus(str, Enum):
"""Status of an invitation."""
PENDING = "pending"
ACCEPTED = "accepted"
EXPIRED = "expired"
CANCELLED = "cancelled"
# ==================== USER & COMPANY MODELS ====================
class Profile(DBModel):
"""Profile model - extends auth.users with app-specific data.
The id is the same as auth.users.id (Supabase handles authentication).
"""
id: Optional[UUID] = None id: Optional[UUID] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
name: str
email: Optional[str] = None # Account Type
company_name: Optional[str] = None account_type: AccountType = AccountType.GHOSTWRITER
linkedin_url: str
# Display name
display_name: Optional[str] = None
# Onboarding
onboarding_status: OnboardingStatus = OnboardingStatus.PENDING
onboarding_data: Dict[str, Any] = Field(default_factory=dict)
# Links
company_id: Optional[UUID] = None
# Fields migrated from customers table
linkedin_url: Optional[str] = None
writing_style_notes: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict) metadata: Dict[str, Any] = Field(default_factory=dict)
profile_picture: Optional[str] = None
creator_email: Optional[str] = None
customer_email: Optional[str] = None
is_active: bool = True
class LinkedInAccount(DBModel):
"""LinkedIn account connection for auto-posting."""
id: Optional[UUID] = None
user_id: UUID
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# LinkedIn Identity
linkedin_user_id: str
linkedin_vanity_name: Optional[str] = None
linkedin_name: Optional[str] = None
linkedin_picture: Optional[str] = None
# OAuth Tokens (encrypted)
access_token: str
refresh_token: Optional[str] = None
token_expires_at: datetime
granted_scopes: List[str] = Field(default_factory=list)
# Status
is_active: bool = True
last_used_at: Optional[datetime] = None
last_error: Optional[str] = None
last_error_at: Optional[datetime] = None
class User(DBModel):
"""User model - combines auth.users data with profile data.
This is a view model that combines data from Supabase auth.users
and our profiles table. Used for compatibility with existing code.
"""
id: Optional[UUID] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# From auth.users
email: str = ""
password_hash: Optional[str] = None # Not stored, Supabase handles it
auth_method: AuthMethod = AuthMethod.LINKEDIN_OAUTH
# LinkedIn OAuth Data (from auth.users.raw_user_meta_data)
linkedin_sub: Optional[str] = None
linkedin_vanity_name: Optional[str] = None
linkedin_name: Optional[str] = None
linkedin_picture: Optional[str] = None
# From profiles table
account_type: AccountType = AccountType.GHOSTWRITER
display_name: Optional[str] = None
onboarding_status: OnboardingStatus = OnboardingStatus.PENDING
onboarding_data: Dict[str, Any] = Field(default_factory=dict)
company_id: Optional[UUID] = None
# Fields migrated from customers table
linkedin_url: Optional[str] = None
writing_style_notes: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
profile_picture: Optional[str] = None
creator_email: Optional[str] = None
customer_email: Optional[str] = None
is_active: bool = True
# Email verification (from auth.users)
email_verified: bool = False
email_verification_token: Optional[str] = None
email_verification_expires_at: Optional[datetime] = None
class Company(DBModel):
"""Company model for company accounts."""
id: Optional[UUID] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# Company Data
name: str
description: Optional[str] = None
website: Optional[str] = None
industry: Optional[str] = None
# Strategy
company_strategy: Dict[str, Any] = Field(default_factory=dict)
owner_user_id: UUID
onboarding_completed: bool = False
# License key reference (limits are stored in license_keys table)
license_key_id: Optional[UUID] = None
class Invitation(DBModel):
"""Invitation model for employee invitations."""
id: Optional[UUID] = None
created_at: Optional[datetime] = None
email: str
token: str
expires_at: datetime
company_id: UUID
invited_by_user_id: UUID
status: InvitationStatus = InvitationStatus.PENDING
accepted_at: Optional[datetime] = None
accepted_by_user_id: Optional[UUID] = None
class ExamplePost(DBModel):
"""Example post model for manual posts during onboarding."""
id: Optional[UUID] = None
user_id: UUID
created_at: Optional[datetime] = None
post_text: str
source: str = "manual" # 'manual' | 'reference_profile'
source_linkedin_url: Optional[str] = None
post_type_id: Optional[UUID] = None
class ReferenceProfile(DBModel):
"""Reference profile for scraping alternative LinkedIn profiles."""
id: Optional[UUID] = None
user_id: UUID
created_at: Optional[datetime] = None
linkedin_url: str
name: Optional[str] = None
posts_scraped: int = 0
# ==================== CONTENT MODELS ====================
class PostType(DBModel): class PostType(DBModel):
"""Post type model for categorizing different types of posts.""" """Post type model for categorizing different types of posts."""
id: Optional[UUID] = None id: Optional[UUID] = None
customer_id: UUID user_id: UUID
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
name: str name: str
@@ -42,7 +231,7 @@ class PostType(DBModel):
class LinkedInProfile(DBModel): class LinkedInProfile(DBModel):
"""LinkedIn profile model.""" """LinkedIn profile model."""
id: Optional[UUID] = None id: Optional[UUID] = None
customer_id: UUID user_id: UUID
scraped_at: Optional[datetime] = None scraped_at: Optional[datetime] = None
profile_data: Dict[str, Any] profile_data: Dict[str, Any]
name: Optional[str] = None name: Optional[str] = None
@@ -55,7 +244,7 @@ class LinkedInProfile(DBModel):
class LinkedInPost(DBModel): class LinkedInPost(DBModel):
"""LinkedIn post model.""" """LinkedIn post model."""
id: Optional[UUID] = None id: Optional[UUID] = None
customer_id: UUID user_id: UUID
scraped_at: Optional[datetime] = None scraped_at: Optional[datetime] = None
post_url: Optional[str] = None post_url: Optional[str] = None
post_text: str post_text: str
@@ -73,7 +262,7 @@ class LinkedInPost(DBModel):
class Topic(DBModel): class Topic(DBModel):
"""Topic model.""" """Topic model."""
id: Optional[UUID] = None id: Optional[UUID] = None
customer_id: UUID user_id: UUID
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
title: str title: str
description: Optional[str] = None description: Optional[str] = None
@@ -82,13 +271,13 @@ class Topic(DBModel):
extraction_confidence: Optional[float] = None extraction_confidence: Optional[float] = None
is_used: bool = False is_used: bool = False
used_at: Optional[datetime] = None used_at: Optional[datetime] = None
target_post_type_id: Optional[UUID] = None # Target post type for this topic target_post_type_id: Optional[UUID] = None
class ProfileAnalysis(DBModel): class ProfileAnalysis(DBModel):
"""Profile analysis model.""" """Profile analysis model."""
id: Optional[UUID] = None id: Optional[UUID] = None
customer_id: UUID user_id: UUID
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
writing_style: Dict[str, Any] writing_style: Dict[str, Any]
tone_analysis: Dict[str, Any] tone_analysis: Dict[str, Any]
@@ -100,19 +289,33 @@ class ProfileAnalysis(DBModel):
class ResearchResult(DBModel): class ResearchResult(DBModel):
"""Research result model.""" """Research result model."""
id: Optional[UUID] = None id: Optional[UUID] = None
customer_id: UUID user_id: UUID
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
query: str query: str
results: Dict[str, Any] results: Dict[str, Any]
suggested_topics: List[Dict[str, Any]] suggested_topics: List[Dict[str, Any]]
source: str = "perplexity" source: str = "perplexity"
target_post_type_id: Optional[UUID] = None # Target post type for this research target_post_type_id: Optional[UUID] = None
class ApiUsageLog(DBModel):
"""API usage log for tracking token usage and costs."""
id: Optional[UUID] = None
user_id: Optional[UUID] = None
provider: str # 'openai' | 'perplexity'
model: str # 'gpt-4o', 'gpt-4o-mini', 'sonar'
operation: str # 'post_creation', 'research', 'profile_analysis', etc.
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
estimated_cost_usd: float = 0.0
created_at: Optional[datetime] = None
class GeneratedPost(DBModel): class GeneratedPost(DBModel):
"""Generated post model.""" """Generated post model."""
id: Optional[UUID] = None id: Optional[UUID] = None
customer_id: UUID user_id: UUID
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
topic_id: Optional[UUID] = None topic_id: Optional[UUID] = None
topic_title: str topic_title: str
@@ -120,7 +323,48 @@ class GeneratedPost(DBModel):
iterations: int = 0 iterations: int = 0
writer_versions: List[str] = Field(default_factory=list) writer_versions: List[str] = Field(default_factory=list)
critic_feedback: List[Dict[str, Any]] = Field(default_factory=list) critic_feedback: List[Dict[str, Any]] = Field(default_factory=list)
status: str = "draft" # draft, approved, published, rejected status: str = "draft" # draft, approved, ready, scheduled, published, rejected
approved_at: Optional[datetime] = None approved_at: Optional[datetime] = None
published_at: Optional[datetime] = None published_at: Optional[datetime] = None
post_type_id: Optional[UUID] = None # Post type used for this generated post post_type_id: Optional[UUID] = None
# Image
image_url: Optional[str] = None
# Scheduling fields
scheduled_at: Optional[datetime] = None
scheduled_by_user_id: Optional[UUID] = None
# Metadata for additional info (e.g., LinkedIn post URL, auto-posting status)
metadata: Optional[Dict[str, Any]] = None
# ==================== LICENSE KEY MODELS ====================
class LicenseKey(DBModel):
"""License key for company registration."""
id: Optional[UUID] = None
key: str
description: Optional[str] = None
# Limits
max_employees: int = 5
max_posts_per_day: int = 10
max_researches_per_day: int = 5
# Usage
used: bool = False
company_id: Optional[UUID] = None
used_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class CompanyDailyQuota(DBModel):
"""Daily usage quota for a company."""
id: Optional[UUID] = None
company_id: UUID
date: date
posts_created: int = 0
researches_created: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

View File

@@ -5,7 +5,7 @@ from uuid import UUID
from loguru import logger from loguru import logger
from src.config import settings from src.config import settings
from src.database import db, Customer, LinkedInProfile, LinkedInPost, Topic from src.database import db, LinkedInProfile, LinkedInPost, Topic
from src.scraper import scraper from src.scraper import scraper
from src.agents import ( from src.agents import (
ProfileAnalyzerAgent, ProfileAnalyzerAgent,
@@ -31,65 +31,80 @@ class WorkflowOrchestrator:
self.critic = CriticAgent() self.critic = CriticAgent()
self.post_classifier = PostClassifierAgent() self.post_classifier = PostClassifierAgent()
self.post_type_analyzer = PostTypeAnalyzerAgent() self.post_type_analyzer = PostTypeAnalyzerAgent()
self._all_agents = [
self.profile_analyzer, self.topic_extractor, self.researcher,
self.writer, self.critic, self.post_classifier, self.post_type_analyzer
]
logger.info("WorkflowOrchestrator initialized") logger.info("WorkflowOrchestrator initialized")
def _set_tracking(self, operation: str, user_id: Optional[str] = None,
company_id: Optional[str] = None):
"""Set tracking context on all agents."""
uid = str(user_id) if user_id else None
comp_id = str(company_id) if company_id else None
for agent in self._all_agents:
agent.set_tracking_context(operation=operation, user_id=uid, company_id=comp_id)
async def _resolve_tracking_ids(self, user_id: UUID) -> dict:
"""Resolve company_id from a user_id for tracking."""
try:
profile = await db.get_profile(user_id)
if profile:
return {
"user_id": str(user_id),
"company_id": str(profile.company_id) if profile.company_id else None
}
except Exception as e:
logger.debug(f"Could not resolve tracking IDs for user {user_id}: {e}")
return {"user_id": str(user_id), "company_id": None}
async def run_initial_setup( async def run_initial_setup(
self, self,
user_id: UUID,
linkedin_url: str, linkedin_url: str,
customer_name: str, profile_data: Dict[str, Any],
customer_data: Dict[str, Any],
post_types_data: Optional[List[Dict[str, Any]]] = None post_types_data: Optional[List[Dict[str, Any]]] = None
) -> Customer: ) -> None:
""" """
Run initial setup for a new customer. Run initial setup for a user.
This includes: This includes:
1. Creating customer record 1. Updating profile with linkedin_url and metadata
2. Creating post types (if provided) 2. Creating post types (if provided)
3. Scraping LinkedIn posts (NO profile scraping) 3. Scraping LinkedIn posts (NO profile scraping)
4. Creating profile from customer_data 4. Creating profile from profile_data
5. Analyzing profile 5. Analyzing profile
6. Extracting topics from existing posts 6. Extracting topics from existing posts
7. Classifying posts by type (if post types exist) 7. Classifying posts by type (if post types exist)
8. Analyzing post types (if enough posts) 8. Analyzing post types (if enough posts)
Args: Args:
user_id: User UUID
linkedin_url: LinkedIn profile URL linkedin_url: LinkedIn profile URL
customer_name: Customer name profile_data: Profile data (writing style notes, etc.)
customer_data: Complete customer data (company, persona, style_guide, etc.)
post_types_data: Optional list of post type definitions post_types_data: Optional list of post type definitions
Returns:
Customer object
""" """
logger.info(f"=== INITIAL SETUP for {customer_name} ===") logger.info(f"=== INITIAL SETUP for user {user_id} ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("initial_setup", **ids)
# Step 1: Check if customer already exists # Step 1: Update profile with linkedin_url
existing_customer = await db.get_customer_by_linkedin(linkedin_url)
if existing_customer:
logger.warning(f"Customer already exists: {existing_customer.id}")
return existing_customer
# Step 2: Create customer
total_steps = 7 if post_types_data else 5 total_steps = 7 if post_types_data else 5
logger.info(f"Step 1/{total_steps}: Creating customer record") logger.info(f"Step 1/{total_steps}: Updating profile")
customer = Customer( await db.update_profile(user_id, {
name=customer_name, "linkedin_url": linkedin_url,
linkedin_url=linkedin_url, "writing_style_notes": profile_data.get("writing_style_notes"),
company_name=customer_data.get("company_name"), "metadata": profile_data
email=customer_data.get("email"), })
metadata=customer_data logger.info(f"Profile updated for user: {user_id}")
)
customer = await db.create_customer(customer)
logger.info(f"Customer created: {customer.id}")
# Step 2.5: Create post types if provided # Step 2: Create post types if provided
created_post_types = [] created_post_types = []
if post_types_data: if post_types_data:
logger.info(f"Step 2/{total_steps}: Creating {len(post_types_data)} post types") logger.info(f"Step 2/{total_steps}: Creating {len(post_types_data)} post types")
for pt_data in post_types_data: for pt_data in post_types_data:
post_type = PostType( post_type = PostType(
customer_id=customer.id, user_id=user_id,
name=pt_data.get("name", "Unnamed"), name=pt_data.get("name", "Unnamed"),
description=pt_data.get("description"), description=pt_data.get("description"),
identifying_hashtags=pt_data.get("identifying_hashtags", []), identifying_hashtags=pt_data.get("identifying_hashtags", []),
@@ -102,19 +117,20 @@ class WorkflowOrchestrator:
created_post_types = await db.create_post_types_bulk(created_post_types) created_post_types = await db.create_post_types_bulk(created_post_types)
logger.info(f"Created {len(created_post_types)} post types") logger.info(f"Created {len(created_post_types)} post types")
# Step 3: Create LinkedIn profile from customer data (NO scraping) # Step 3: Create LinkedIn profile from profile data (NO scraping)
step_num = 3 if post_types_data else 2 step_num = 3 if post_types_data else 2
logger.info(f"Step {step_num}/{total_steps}: Creating LinkedIn profile from provided data") logger.info(f"Step {step_num}/{total_steps}: Creating LinkedIn profile from provided data")
profile = await db.get_profile(user_id)
linkedin_profile = LinkedInProfile( linkedin_profile = LinkedInProfile(
customer_id=customer.id, user_id=user_id,
profile_data={ profile_data={
"persona": customer_data.get("persona"), "persona": profile_data.get("persona"),
"form_of_address": customer_data.get("form_of_address"), "form_of_address": profile_data.get("form_of_address"),
"style_guide": customer_data.get("style_guide"), "style_guide": profile_data.get("style_guide"),
"linkedin_url": linkedin_url "linkedin_url": linkedin_url
}, },
name=customer_name, name=profile.display_name or "",
headline=customer_data.get("persona", "")[:100] if customer_data.get("persona") else None headline=profile_data.get("persona", "")[:100] if profile_data.get("persona") else None
) )
await db.save_linkedin_profile(linkedin_profile) await db.save_linkedin_profile(linkedin_profile)
logger.info("LinkedIn profile saved") logger.info("LinkedIn profile saved")
@@ -129,7 +145,7 @@ class WorkflowOrchestrator:
linkedin_posts = [] linkedin_posts = []
for post_data in parsed_posts: for post_data in parsed_posts:
post = LinkedInPost( post = LinkedInPost(
customer_id=customer.id, user_id=user_id,
**post_data **post_data
) )
linkedin_posts.append(post) linkedin_posts.append(post)
@@ -151,13 +167,13 @@ class WorkflowOrchestrator:
profile_analysis = await self.profile_analyzer.process( profile_analysis = await self.profile_analyzer.process(
profile=linkedin_profile, profile=linkedin_profile,
posts=linkedin_posts, posts=linkedin_posts,
customer_data=customer_data customer_data=profile_data
) )
# Save profile analysis # Save profile analysis
from src.database.models import ProfileAnalysis from src.database.models import ProfileAnalysis
analysis_record = ProfileAnalysis( analysis_record = ProfileAnalysis(
customer_id=customer.id, user_id=user_id,
writing_style=profile_analysis.get("writing_style", {}), writing_style=profile_analysis.get("writing_style", {}),
tone_analysis=profile_analysis.get("tone_analysis", {}), tone_analysis=profile_analysis.get("tone_analysis", {}),
topic_patterns=profile_analysis.get("topic_patterns", {}), topic_patterns=profile_analysis.get("topic_patterns", {}),
@@ -177,7 +193,7 @@ class WorkflowOrchestrator:
try: try:
topics = await self.topic_extractor.process( topics = await self.topic_extractor.process(
posts=linkedin_posts, posts=linkedin_posts,
customer_id=customer.id # Pass UUID directly user_id=user_id
) )
if topics: if topics:
await db.save_topics(topics) await db.save_topics(topics)
@@ -192,40 +208,41 @@ class WorkflowOrchestrator:
# Step 7: Classify posts # Step 7: Classify posts
logger.info(f"Step {total_steps - 1}/{total_steps}: Classifying posts by type") logger.info(f"Step {total_steps - 1}/{total_steps}: Classifying posts by type")
try: try:
await self.classify_posts(customer.id) await self.classify_posts(user_id)
except Exception as e: except Exception as e:
logger.error(f"Post classification failed: {e}", exc_info=True) logger.error(f"Post classification failed: {e}", exc_info=True)
# Step 8: Analyze post types # Step 8: Analyze post types
logger.info(f"Step {total_steps}/{total_steps}: Analyzing post types") logger.info(f"Step {total_steps}/{total_steps}: Analyzing post types")
try: try:
await self.analyze_post_types(customer.id) await self.analyze_post_types(user_id)
except Exception as e: except Exception as e:
logger.error(f"Post type analysis failed: {e}", exc_info=True) logger.error(f"Post type analysis failed: {e}", exc_info=True)
logger.info(f"Step {total_steps}/{total_steps}: Initial setup complete!") logger.info(f"Step {total_steps}/{total_steps}: Initial setup complete!")
return customer
async def classify_posts(self, customer_id: UUID) -> int: async def classify_posts(self, user_id: UUID) -> int:
""" """
Classify unclassified posts for a customer. Classify unclassified posts for a user.
Args: Args:
customer_id: Customer UUID user_id: User UUID
Returns: Returns:
Number of posts classified Number of posts classified
""" """
logger.info(f"=== CLASSIFYING POSTS for customer {customer_id} ===") logger.info(f"=== CLASSIFYING POSTS for user {user_id} ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("classify_posts", **ids)
# Get post types # Get post types
post_types = await db.get_post_types(customer_id) post_types = await db.get_post_types(user_id)
if not post_types: if not post_types:
logger.info("No post types defined, skipping classification") logger.info("No post types defined, skipping classification")
return 0 return 0
# Get unclassified posts # Get unclassified posts
posts = await db.get_unclassified_posts(customer_id) posts = await db.get_unclassified_posts(user_id)
if not posts: if not posts:
logger.info("No unclassified posts found") logger.info("No unclassified posts found")
return 0 return 0
@@ -243,20 +260,22 @@ class WorkflowOrchestrator:
return 0 return 0
async def analyze_post_types(self, customer_id: UUID) -> Dict[str, Any]: async def analyze_post_types(self, user_id: UUID) -> Dict[str, Any]:
""" """
Analyze all post types for a customer. Analyze all post types for a user.
Args: Args:
customer_id: Customer UUID user_id: User UUID
Returns: Returns:
Dictionary with analysis results per post type Dictionary with analysis results per post type
""" """
logger.info(f"=== ANALYZING POST TYPES for customer {customer_id} ===") logger.info(f"=== ANALYZING POST TYPES for user {user_id} ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("analyze_post_types", **ids)
# Get post types # Get post types
post_types = await db.get_post_types(customer_id) post_types = await db.get_post_types(user_id)
if not post_types: if not post_types:
logger.info("No post types defined") logger.info("No post types defined")
return {} return {}
@@ -264,7 +283,7 @@ class WorkflowOrchestrator:
results = {} results = {}
for post_type in post_types: for post_type in post_types:
# Get posts for this type # Get posts for this type
posts = await db.get_posts_by_type(customer_id, post_type.id) posts = await db.get_posts_by_type(user_id, post_type.id)
if len(posts) < self.post_type_analyzer.MIN_POSTS_FOR_ANALYSIS: if len(posts) < self.post_type_analyzer.MIN_POSTS_FOR_ANALYSIS:
logger.info(f"Post type '{post_type.name}' has only {len(posts)} posts, skipping analysis") logger.info(f"Post type '{post_type.name}' has only {len(posts)} posts, skipping analysis")
@@ -292,22 +311,24 @@ class WorkflowOrchestrator:
async def research_new_topics( async def research_new_topics(
self, self,
customer_id: UUID, user_id: UUID,
progress_callback: Optional[Callable[[str, int, int], None]] = None, progress_callback: Optional[Callable[[str, int, int], None]] = None,
post_type_id: Optional[UUID] = None post_type_id: Optional[UUID] = None
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Research new content topics for a customer. Research new content topics for a user.
Args: Args:
customer_id: Customer UUID user_id: User UUID
progress_callback: Optional callback(message, current_step, total_steps) progress_callback: Optional callback(message, current_step, total_steps)
post_type_id: Optional post type to target research for post_type_id: Optional post type to target research for
Returns: Returns:
List of suggested topics List of suggested topics
""" """
logger.info(f"=== RESEARCHING NEW TOPICS for customer {customer_id} ===") logger.info(f"=== RESEARCHING NEW TOPICS for user {user_id} ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("research", **ids)
# Get post type context if specified # Get post type context if specified
post_type = None post_type = None
@@ -324,7 +345,7 @@ class WorkflowOrchestrator:
# Step 1: Get profile analysis # Step 1: Get profile analysis
report_progress("Lade Profil-Analyse...", 1) report_progress("Lade Profil-Analyse...", 1)
profile_analysis = await db.get_profile_analysis(customer_id) profile_analysis = await db.get_profile_analysis(user_id)
if not profile_analysis: if not profile_analysis:
raise ValueError("Profile analysis not found. Run initial setup first.") raise ValueError("Profile analysis not found. Run initial setup first.")
@@ -333,12 +354,12 @@ class WorkflowOrchestrator:
existing_topics = set() existing_topics = set()
# From topics table # From topics table
existing_topics_records = await db.get_topics(customer_id) existing_topics_records = await db.get_topics(user_id)
for t in existing_topics_records: for t in existing_topics_records:
existing_topics.add(t.title) existing_topics.add(t.title)
# From previous research results # From previous research results
all_research = await db.get_all_research(customer_id) all_research = await db.get_all_research(user_id)
for research in all_research: for research in all_research:
if research.suggested_topics: if research.suggested_topics:
for topic in research.suggested_topics: for topic in research.suggested_topics:
@@ -346,7 +367,7 @@ class WorkflowOrchestrator:
existing_topics.add(topic["title"]) existing_topics.add(topic["title"])
# From generated posts # From generated posts
generated_posts = await db.get_generated_posts(customer_id) generated_posts = await db.get_generated_posts(user_id)
for post in generated_posts: for post in generated_posts:
if post.topic_title: if post.topic_title:
existing_topics.add(post.topic_title) existing_topics.add(post.topic_title)
@@ -354,15 +375,15 @@ class WorkflowOrchestrator:
existing_topics = list(existing_topics) existing_topics = list(existing_topics)
logger.info(f"Found {len(existing_topics)} existing topics to avoid") logger.info(f"Found {len(existing_topics)} existing topics to avoid")
# Get customer data # Get profile data
customer = await db.get_customer(customer_id) profile = await db.get_profile(user_id)
# Get example posts to understand the person's actual content style # Get example posts to understand the person's actual content style
# If post_type_id is specified, only use posts of that type # If post_type_id is specified, only use posts of that type
if post_type_id: if post_type_id:
linkedin_posts = await db.get_posts_by_type(customer_id, post_type_id) linkedin_posts = await db.get_posts_by_type(user_id, post_type_id)
else: else:
linkedin_posts = await db.get_linkedin_posts(customer_id) linkedin_posts = await db.get_linkedin_posts(user_id)
example_post_texts = [ example_post_texts = [
post.post_text for post in linkedin_posts post.post_text for post in linkedin_posts
@@ -376,7 +397,7 @@ class WorkflowOrchestrator:
research_results = await self.researcher.process( research_results = await self.researcher.process(
profile_analysis=profile_analysis.full_analysis, profile_analysis=profile_analysis.full_analysis,
existing_topics=existing_topics, existing_topics=existing_topics,
customer_data=customer.metadata, customer_data=profile.metadata,
example_posts=example_post_texts, example_posts=example_post_texts,
post_type=post_type, post_type=post_type,
post_type_analysis=post_type_analysis post_type_analysis=post_type_analysis
@@ -386,8 +407,8 @@ class WorkflowOrchestrator:
report_progress("Speichere Ergebnisse...", 4) report_progress("Speichere Ergebnisse...", 4)
from src.database.models import ResearchResult from src.database.models import ResearchResult
research_record = ResearchResult( research_record = ResearchResult(
customer_id=customer_id, user_id=user_id,
query=f"New topics for {customer.name}" + (f" ({post_type.name})" if post_type else ""), query=f"New topics for {profile.display_name}" + (f" ({post_type.name})" if post_type else ""),
results={"raw_response": research_results["raw_response"]}, results={"raw_response": research_results["raw_response"]},
suggested_topics=research_results["suggested_topics"], suggested_topics=research_results["suggested_topics"],
target_post_type_id=post_type_id target_post_type_id=post_type_id
@@ -397,19 +418,135 @@ class WorkflowOrchestrator:
return research_results["suggested_topics"] return research_results["suggested_topics"]
async def generate_hooks(
self,
user_id: UUID,
topic: Dict[str, Any],
user_thoughts: str = "",
post_type_id: Optional[UUID] = None
) -> List[Dict[str, str]]:
"""
Generate 4 hook options for a topic.
Args:
user_id: User UUID
topic: Topic dictionary
user_thoughts: User's personal thoughts about the topic
post_type_id: Optional post type for context
Returns:
List of {"hook": "...", "style": "..."} dictionaries
"""
logger.info(f"=== GENERATING HOOKS for topic: {topic.get('title')} ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("generate_hooks", **ids)
# Get profile analysis for style matching
profile_analysis = await db.get_profile_analysis(user_id)
if not profile_analysis:
raise ValueError("Profile analysis not found. Run initial setup first.")
# Get post type context if specified
post_type = None
if post_type_id:
post_type = await db.get_post_type(post_type_id)
# Generate hooks via writer agent
hooks = await self.writer.generate_hooks(
topic=topic,
profile_analysis=profile_analysis.full_analysis,
user_thoughts=user_thoughts,
post_type=post_type
)
logger.info(f"Generated {len(hooks)} hooks")
return hooks
async def generate_improvement_suggestions(
self,
user_id: UUID,
post_content: str,
critic_feedback: Optional[Dict[str, Any]] = None
) -> List[Dict[str, str]]:
"""
Generate improvement suggestions for an existing post.
Args:
user_id: User UUID
post_content: The current post content
critic_feedback: Optional feedback from the critic
Returns:
List of {"label": "...", "action": "..."} dictionaries
"""
logger.info("=== GENERATING IMPROVEMENT SUGGESTIONS ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("improvement_suggestions", **ids)
# Get profile analysis for style matching
profile_analysis = await db.get_profile_analysis(user_id)
if not profile_analysis:
raise ValueError("Profile analysis not found.")
suggestions = await self.writer.generate_improvement_suggestions(
post_content=post_content,
profile_analysis=profile_analysis.full_analysis,
critic_feedback=critic_feedback
)
logger.info(f"Generated {len(suggestions)} improvement suggestions")
return suggestions
async def apply_suggestion_to_post(
self,
user_id: UUID,
post_content: str,
suggestion: str
) -> str:
"""
Apply a suggestion to a post and return the improved version.
Args:
user_id: User UUID
post_content: The current post content
suggestion: The suggestion to apply
Returns:
The improved post content
"""
logger.info(f"=== APPLYING SUGGESTION TO POST ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("apply_suggestion", **ids)
# Get profile analysis for style matching
profile_analysis = await db.get_profile_analysis(user_id)
if not profile_analysis:
raise ValueError("Profile analysis not found.")
improved_post = await self.writer.apply_suggestion(
post_content=post_content,
suggestion=suggestion,
profile_analysis=profile_analysis.full_analysis
)
logger.info("Successfully applied suggestion to post")
return improved_post
async def create_post( async def create_post(
self, self,
customer_id: UUID, user_id: UUID,
topic: Dict[str, Any], topic: Dict[str, Any],
max_iterations: int = 3, max_iterations: int = 3,
progress_callback: Optional[Callable[[str, int, int, Optional[int], Optional[List], Optional[List]], None]] = None, progress_callback: Optional[Callable[[str, int, int, Optional[int], Optional[List], Optional[List]], None]] = None,
post_type_id: Optional[UUID] = None post_type_id: Optional[UUID] = None,
user_thoughts: str = "",
selected_hook: str = ""
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Create a LinkedIn post through writer-critic iteration. Create a LinkedIn post through writer-critic iteration.
Args: Args:
customer_id: Customer UUID user_id: User UUID
topic: Topic dictionary topic: Topic dictionary
max_iterations: Maximum number of writer-critic iterations max_iterations: Maximum number of writer-critic iterations
progress_callback: Optional callback(message, iteration, max_iterations, score, versions, feedback_list) progress_callback: Optional callback(message, iteration, max_iterations, score, versions, feedback_list)
@@ -419,6 +556,8 @@ class WorkflowOrchestrator:
Dictionary with final post and metadata Dictionary with final post and metadata
""" """
logger.info(f"=== CREATING POST for topic: {topic.get('title')} ===") logger.info(f"=== CREATING POST for topic: {topic.get('title')} ===")
ids = await self._resolve_tracking_ids(user_id)
self._set_tracking("post_creation", **ids)
def report_progress(message: str, iteration: int, score: Optional[int] = None, def report_progress(message: str, iteration: int, score: Optional[int] = None,
versions: Optional[List] = None, feedback_list: Optional[List] = None): versions: Optional[List] = None, feedback_list: Optional[List] = None):
@@ -427,7 +566,7 @@ class WorkflowOrchestrator:
# Get profile analysis # Get profile analysis
report_progress("Lade Profil-Analyse...", 0, None, [], []) report_progress("Lade Profil-Analyse...", 0, None, [], [])
profile_analysis = await db.get_profile_analysis(customer_id) profile_analysis = await db.get_profile_analysis(user_id)
if not profile_analysis: if not profile_analysis:
raise ValueError("Profile analysis not found. Run initial setup first.") raise ValueError("Profile analysis not found. Run initial setup first.")
@@ -440,16 +579,16 @@ class WorkflowOrchestrator:
post_type_analysis = post_type.analysis post_type_analysis = post_type.analysis
logger.info(f"Using post type '{post_type.name}' for writing") logger.info(f"Using post type '{post_type.name}' for writing")
# Load customer's real posts as style examples # Load user's real posts as style examples
# If post_type_id is specified, only use posts of that type # If post_type_id is specified, only use posts of that type
if post_type_id: if post_type_id:
linkedin_posts = await db.get_posts_by_type(customer_id, post_type_id) linkedin_posts = await db.get_posts_by_type(user_id, post_type_id)
if len(linkedin_posts) < 3: if len(linkedin_posts) < 3:
# Fall back to all posts if not enough type-specific posts # Fall back to all posts if not enough type-specific posts
linkedin_posts = await db.get_linkedin_posts(customer_id) linkedin_posts = await db.get_linkedin_posts(user_id)
logger.info("Not enough type-specific posts, using all posts") logger.info("Not enough type-specific posts, using all posts")
else: else:
linkedin_posts = await db.get_linkedin_posts(customer_id) linkedin_posts = await db.get_linkedin_posts(user_id)
example_post_texts = [ example_post_texts = [
post.post_text for post in linkedin_posts post.post_text for post in linkedin_posts
@@ -458,7 +597,7 @@ class WorkflowOrchestrator:
logger.info(f"Loaded {len(example_post_texts)} example posts for style reference") logger.info(f"Loaded {len(example_post_texts)} example posts for style reference")
# Extract lessons from past feedback (if enabled) # Extract lessons from past feedback (if enabled)
feedback_lessons = await self._extract_recurring_feedback(customer_id) feedback_lessons = await self._extract_recurring_feedback(user_id)
# Initialize tracking # Initialize tracking
writer_versions = [] writer_versions = []
@@ -467,6 +606,15 @@ class WorkflowOrchestrator:
approved = False approved = False
iteration = 0 iteration = 0
# Load company strategy if user belongs to a company
company_strategy = None
profile = await db.get_profile(user_id)
if profile and profile.company_id:
company = await db.get_company(profile.company_id)
if company and company.company_strategy:
company_strategy = company.company_strategy
logger.info(f"Loaded company strategy for post creation: {company.name}")
# Writer-Critic loop # Writer-Critic loop
while iteration < max_iterations and not approved: while iteration < max_iterations and not approved:
iteration += 1 iteration += 1
@@ -482,7 +630,10 @@ class WorkflowOrchestrator:
example_posts=example_post_texts, example_posts=example_post_texts,
learned_lessons=feedback_lessons, # Pass lessons from past feedback learned_lessons=feedback_lessons, # Pass lessons from past feedback
post_type=post_type, post_type=post_type,
post_type_analysis=post_type_analysis post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy # NEW: Pass company strategy
) )
else: else:
# Revision based on feedback - pass full critic result for structured changes # Revision based on feedback - pass full critic result for structured changes
@@ -497,7 +648,10 @@ class WorkflowOrchestrator:
critic_result=last_feedback, # Pass full critic result with specific_changes critic_result=last_feedback, # Pass full critic result with specific_changes
learned_lessons=feedback_lessons, # Also for revisions learned_lessons=feedback_lessons, # Also for revisions
post_type=post_type, post_type=post_type,
post_type_analysis=post_type_analysis post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy # NEW: Pass company strategy
) )
writer_versions.append(current_post) writer_versions.append(current_post)
@@ -538,19 +692,14 @@ class WorkflowOrchestrator:
if iteration < max_iterations: if iteration < max_iterations:
logger.info("Post needs revision, continuing...") logger.info("Post needs revision, continuing...")
# Determine final status based on score # All new posts start as draft - user moves them via Kanban board
final_score = critic_feedback_list[-1].get("overall_score", 0) if critic_feedback_list else 0 final_score = critic_feedback_list[-1].get("overall_score", 0) if critic_feedback_list else 0
if approved and final_score >= 85: status = "draft"
status = "approved"
elif approved and final_score >= 80:
status = "approved" # Auto-approved
else:
status = "draft"
# Save generated post # Save generated post
from src.database.models import GeneratedPost from src.database.models import GeneratedPost
generated_post = GeneratedPost( generated_post = GeneratedPost(
customer_id=customer_id, user_id=user_id,
topic_title=topic.get("title", "Unknown"), topic_title=topic.get("title", "Unknown"),
post_content=current_post, post_content=current_post,
iterations=iteration, iterations=iteration,
@@ -573,12 +722,12 @@ class WorkflowOrchestrator:
"critic_feedback": critic_feedback_list "critic_feedback": critic_feedback_list
} }
async def _extract_recurring_feedback(self, customer_id: UUID) -> Dict[str, Any]: async def _extract_recurring_feedback(self, user_id: UUID) -> Dict[str, Any]:
""" """
Extract recurring feedback patterns from past generated posts. Extract recurring feedback patterns from past generated posts.
Args: Args:
customer_id: Customer UUID user_id: User UUID
Returns: Returns:
Dictionary with recurring improvements and lessons learned Dictionary with recurring improvements and lessons learned
@@ -587,7 +736,7 @@ class WorkflowOrchestrator:
return {"lessons": [], "patterns": {}} return {"lessons": [], "patterns": {}}
# Get recent generated posts with their critic feedback # Get recent generated posts with their critic feedback
generated_posts = await db.get_generated_posts(customer_id) generated_posts = await db.get_generated_posts(user_id)
if not generated_posts: if not generated_posts:
return {"lessons": [], "patterns": {}} return {"lessons": [], "patterns": {}}
@@ -683,26 +832,26 @@ class WorkflowOrchestrator:
} }
} }
async def get_customer_status(self, customer_id: UUID) -> Dict[str, Any]: async def get_user_status(self, user_id: UUID) -> Dict[str, Any]:
""" """
Get status information for a customer. Get status information for a user.
Args: Args:
customer_id: Customer UUID user_id: User UUID
Returns: Returns:
Status dictionary Status dictionary
""" """
customer = await db.get_customer(customer_id) profile = await db.get_profile(user_id)
if not customer: if not profile:
raise ValueError("Customer not found") raise ValueError("User not found")
profile = await db.get_linkedin_profile(customer_id) linkedin_profile = await db.get_linkedin_profile(user_id)
posts = await db.get_linkedin_posts(customer_id) posts = await db.get_linkedin_posts(user_id)
analysis = await db.get_profile_analysis(customer_id) analysis = await db.get_profile_analysis(user_id)
generated_posts = await db.get_generated_posts(customer_id) generated_posts = await db.get_generated_posts(user_id)
all_research = await db.get_all_research(customer_id) all_research = await db.get_all_research(user_id)
post_types = await db.get_post_types(customer_id) post_types = await db.get_post_types(user_id)
# Count total research entries # Count total research entries
research_count = len(all_research) research_count = len(all_research)

0
src/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,481 @@
"""Background job system for running AI analyses without blocking the UI."""
import asyncio
from datetime import datetime
from enum import Enum
from typing import Dict, Any, Optional, Callable, Awaitable
from uuid import UUID, uuid4
from dataclasses import dataclass, field
from loguru import logger
class JobStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class JobType(str, Enum):
POST_SCRAPING = "post_scraping"
PROFILE_ANALYSIS = "profile_analysis"
POST_CATEGORIZATION = "post_categorization"
POST_TYPE_ANALYSIS = "post_type_analysis"
@dataclass
class BackgroundJob:
id: str
job_type: JobType
user_id: str
status: JobStatus = JobStatus.PENDING
progress: int = 0
message: str = ""
created_at: datetime = field(default_factory=datetime.utcnow)
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
error: Optional[str] = None
result: Optional[Dict[str, Any]] = None
class BackgroundJobManager:
"""Manages background jobs for AI analyses."""
def __init__(self):
self._jobs: Dict[str, BackgroundJob] = {}
self._user_jobs: Dict[str, list] = {} # user_id -> list of job_ids
self._listeners: Dict[str, list] = {} # user_id -> list of callbacks
def create_job(self, job_type: JobType, user_id: str) -> BackgroundJob:
"""Create a new background job."""
job_id = str(uuid4())
job = BackgroundJob(
id=job_id,
job_type=job_type,
user_id=user_id
)
self._jobs[job_id] = job
if user_id not in self._user_jobs:
self._user_jobs[user_id] = []
self._user_jobs[user_id].append(job_id)
logger.info(f"Created background job {job_id} of type {job_type} for user {user_id}")
return job
def get_job(self, job_id: str) -> Optional[BackgroundJob]:
"""Get a job by ID."""
return self._jobs.get(job_id)
def get_user_jobs(self, user_id: str) -> list[BackgroundJob]:
"""Get all jobs for a user."""
job_ids = self._user_jobs.get(user_id, [])
return [self._jobs[jid] for jid in job_ids if jid in self._jobs]
def get_active_jobs(self, user_id: str) -> list[BackgroundJob]:
"""Get running/pending jobs for a user."""
return [
j for j in self.get_user_jobs(user_id)
if j.status in (JobStatus.PENDING, JobStatus.RUNNING)
]
async def update_job(
self,
job_id: str,
status: Optional[JobStatus] = None,
progress: Optional[int] = None,
message: Optional[str] = None,
error: Optional[str] = None,
result: Optional[Dict[str, Any]] = None
):
"""Update a job's status and notify listeners."""
job = self._jobs.get(job_id)
if not job:
return
if status:
job.status = status
if status == JobStatus.RUNNING and not job.started_at:
job.started_at = datetime.utcnow()
elif status in (JobStatus.COMPLETED, JobStatus.FAILED):
job.completed_at = datetime.utcnow()
if progress is not None:
job.progress = progress
if message:
job.message = message
if error:
job.error = error
if result:
job.result = result
# Notify listeners
await self._notify_listeners(job.user_id, job)
async def _notify_listeners(self, user_id: str, job: BackgroundJob):
"""Notify all listeners for a user about job updates."""
listeners = self._listeners.get(user_id, [])
for callback in listeners:
try:
await callback(job)
except Exception as e:
logger.error(f"Error notifying listener: {e}")
def add_listener(self, user_id: str, callback: Callable[[BackgroundJob], Awaitable[None]]):
"""Add a listener for job updates."""
if user_id not in self._listeners:
self._listeners[user_id] = []
self._listeners[user_id].append(callback)
def remove_listener(self, user_id: str, callback: Callable[[BackgroundJob], Awaitable[None]]):
"""Remove a listener."""
if user_id in self._listeners:
try:
self._listeners[user_id].remove(callback)
except ValueError:
pass
def cleanup_old_jobs(self, max_age_hours: int = 24):
"""Remove completed jobs older than max_age_hours."""
cutoff = datetime.utcnow()
to_remove = []
for job_id, job in self._jobs.items():
if job.completed_at:
age = (cutoff - job.completed_at).total_seconds() / 3600
if age > max_age_hours:
to_remove.append(job_id)
for job_id in to_remove:
job = self._jobs.pop(job_id, None)
if job:
user_jobs = self._user_jobs.get(job.user_id, [])
if job_id in user_jobs:
user_jobs.remove(job_id)
if to_remove:
logger.info(f"Cleaned up {len(to_remove)} old background jobs")
# Global instance
job_manager = BackgroundJobManager()
async def run_post_scraping(user_id: UUID, linkedin_url: str, job_id: str):
"""Run LinkedIn post scraping in background."""
from src.database.client import DatabaseClient
from src.scraper import scraper
from src.database.models import LinkedInPost
db = DatabaseClient()
try:
await job_manager.update_job(
job_id,
status=JobStatus.RUNNING,
progress=10,
message="Starte LinkedIn-Scraping..."
)
# Scrape posts
await job_manager.update_job(
job_id,
progress=30,
message="Lade Posts von LinkedIn..."
)
raw_posts = await scraper.scrape_posts(linkedin_url, limit=50)
parsed_posts = scraper.parse_posts_data(raw_posts)
await job_manager.update_job(
job_id,
progress=60,
message=f"{len(parsed_posts)} Posts gefunden, speichere..."
)
linkedin_posts = []
profile_picture = None
for post_data in parsed_posts:
post = LinkedInPost(user_id=user_id, **post_data)
linkedin_posts.append(post)
# Extract profile picture from first post with author data
if not profile_picture and post_data.get("raw_data"):
author = post_data["raw_data"].get("author", {})
if author and isinstance(author, dict):
profile_picture = author.get("profile_picture")
if linkedin_posts:
await db.save_linkedin_posts(linkedin_posts)
# Save profile picture to profile
if profile_picture:
await db.update_profile(user_id, {"profile_picture": profile_picture})
await job_manager.update_job(
job_id,
status=JobStatus.COMPLETED,
progress=100,
message=f"{len(linkedin_posts)} Posts gespeichert!",
result={"posts_count": len(linkedin_posts), "profile_picture": profile_picture}
)
logger.info(f"Post scraping completed for user {user_id}: {len(linkedin_posts)} posts")
except Exception as e:
logger.error(f"Post scraping failed: {e}")
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error=str(e)
)
async def run_profile_analysis(user_id: UUID, job_id: str):
"""Run profile analysis in background."""
from src.database.client import DatabaseClient
from src.agents.profile_analyzer import ProfileAnalyzerAgent
from src.database.models import ProfileAnalysis
db = DatabaseClient()
try:
await job_manager.update_job(
job_id,
status=JobStatus.RUNNING,
progress=10,
message="Lade LinkedIn-Posts..."
)
# Get posts and profile
posts = await db.get_linkedin_posts(user_id)
if not posts:
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error="Keine Posts gefunden"
)
return
linkedin_profile = await db.get_linkedin_profile(user_id)
user_profile = await db.get_profile(user_id)
await job_manager.update_job(
job_id,
progress=30,
message=f"Analysiere {len(posts)} Posts..."
)
# Run analysis
analyzer = ProfileAnalyzerAgent()
# Prepare user data
user_data = {
"name": user_profile.display_name if user_profile else "",
"company": "",
"writing_style_notes": user_profile.writing_style_notes if user_profile else ""
}
# Create a minimal linkedin profile if none exists
if not linkedin_profile:
from src.database.models import LinkedInProfile
linkedin_profile = LinkedInProfile(
user_id=user_id,
profile_data={},
name=user_profile.display_name if user_profile else ""
)
analysis_result = await analyzer.process(linkedin_profile, posts, user_data)
await job_manager.update_job(
job_id,
progress=80,
message="Speichere Analyse..."
)
# Save analysis
profile_analysis = ProfileAnalysis(
user_id=user_id,
writing_style=analysis_result.get("writing_style", {}),
tone_analysis=analysis_result.get("tone_analysis", {}),
topic_patterns=analysis_result.get("topic_patterns", {}),
audience_insights=analysis_result.get("audience_insights", {}),
full_analysis=analysis_result
)
await db.save_profile_analysis(profile_analysis)
await job_manager.update_job(
job_id,
status=JobStatus.COMPLETED,
progress=100,
message="Profil-Analyse abgeschlossen!"
)
logger.info(f"Profile analysis completed for user {user_id}")
except Exception as e:
logger.error(f"Profile analysis failed: {e}")
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error=str(e)
)
async def run_post_categorization(user_id: UUID, job_id: str):
"""Run automatic post categorization in background."""
from src.database.client import DatabaseClient
from src.agents.post_classifier import PostClassifierAgent
db = DatabaseClient()
try:
await job_manager.update_job(
job_id,
status=JobStatus.RUNNING,
progress=10,
message="Lade Posts und Typen..."
)
# Get posts and types
posts = await db.get_unclassified_posts(user_id)
post_types = await db.get_post_types(user_id)
if not posts:
await job_manager.update_job(
job_id,
status=JobStatus.COMPLETED,
progress=100,
message="Alle Posts sind bereits kategorisiert!"
)
return
if not post_types:
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error="Keine Post-Typen definiert"
)
return
await job_manager.update_job(
job_id,
progress=30,
message=f"Kategorisiere {len(posts)} Posts..."
)
# Run classification
classifier = PostClassifierAgent()
classifications = await classifier.process(posts, post_types)
await job_manager.update_job(
job_id,
progress=70,
message="Speichere Kategorisierungen..."
)
# Save classifications
if classifications:
await db.update_posts_classification_bulk(classifications)
await job_manager.update_job(
job_id,
status=JobStatus.COMPLETED,
progress=100,
message=f"{len(classifications)} Posts kategorisiert!"
)
logger.info(f"Post categorization completed for user {user_id}: {len(classifications)} posts")
except Exception as e:
logger.error(f"Post categorization failed: {e}")
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error=str(e)
)
async def run_post_type_analysis(user_id: UUID, job_id: str):
"""Run post type analysis in background."""
from src.database.client import DatabaseClient
from src.agents.post_type_analyzer import PostTypeAnalyzerAgent
db = DatabaseClient()
try:
await job_manager.update_job(
job_id,
status=JobStatus.RUNNING,
progress=10,
message="Lade Post-Typen..."
)
post_types = await db.get_post_types(user_id)
if not post_types:
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error="Keine Post-Typen definiert"
)
return
analyzer = PostTypeAnalyzerAgent()
total = len(post_types)
for i, post_type in enumerate(post_types):
await job_manager.update_job(
job_id,
progress=int(10 + (80 * i / total)),
message=f"Analysiere '{post_type.name}'..."
)
# Get posts for this type
posts = await db.get_posts_by_type(user_id, post_type.id)
if posts:
analysis = await analyzer.process(post_type, posts)
await db.update_post_type_analysis(post_type.id, analysis, len(posts))
await job_manager.update_job(
job_id,
status=JobStatus.COMPLETED,
progress=100,
message=f"{total} Post-Typen analysiert!"
)
logger.info(f"Post type analysis completed for user {user_id}")
except Exception as e:
logger.error(f"Post type analysis failed: {e}")
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error=str(e)
)
async def run_full_analysis_pipeline(user_id: UUID):
"""Run the full analysis pipeline in sequence: Profile -> Categorization -> Post Types."""
logger.info(f"Starting full analysis pipeline for user {user_id}")
# 1. Profile Analysis
job1 = job_manager.create_job(JobType.PROFILE_ANALYSIS, str(user_id))
await run_profile_analysis(user_id, job1.id)
if job1.status == JobStatus.FAILED:
logger.warning(f"Profile analysis failed, continuing with categorization")
# 2. Post Categorization
job2 = job_manager.create_job(JobType.POST_CATEGORIZATION, str(user_id))
await run_post_categorization(user_id, job2.id)
if job2.status == JobStatus.FAILED:
logger.warning(f"Post categorization failed, continuing with post type analysis")
# 3. Post Type Analysis
job3 = job_manager.create_job(JobType.POST_TYPE_ANALYSIS, str(user_id))
await run_post_type_analysis(user_id, job3.id)
logger.info(f"Full analysis pipeline completed for user {user_id}")

View File

@@ -0,0 +1,467 @@
"""Email service for sending post approval emails."""
import smtplib
import secrets
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from uuid import UUID
from loguru import logger
from src.config import settings
# In-memory token store (in production, use Redis or database)
_email_tokens: Dict[str, Dict[str, Any]] = {}
def generate_token(post_id: UUID, action: str, expires_hours: int = 72) -> str:
"""Generate a unique token for email action."""
token = secrets.token_urlsafe(32)
_email_tokens[token] = {
"post_id": str(post_id),
"action": action,
"expires_at": datetime.utcnow() + timedelta(hours=expires_hours),
"used": False
}
return token
def validate_token(token: str) -> Optional[Dict[str, Any]]:
"""Validate and return token data if valid."""
if token not in _email_tokens:
return None
token_data = _email_tokens[token]
if token_data["used"]:
return None
if datetime.utcnow() > token_data["expires_at"]:
return None
return token_data
def mark_token_used(token: str):
"""Mark a token as used."""
if token in _email_tokens:
_email_tokens[token]["used"] = True
def send_email(to_email: str, subject: str, html_content: str) -> bool:
"""Send an email using SMTP."""
if not settings.smtp_host or not settings.smtp_user:
logger.warning("SMTP not configured, skipping email send")
return False
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_user}>"
msg["To"] = to_email
html_part = MIMEText(html_content, "html")
msg.attach(html_part)
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
server.starttls()
server.login(settings.smtp_user, settings.smtp_password)
server.sendmail(settings.smtp_user, to_email, msg.as_string())
logger.info(f"Email sent to {to_email}: {subject}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {e}")
return False
def send_approval_request_email(
to_email: str,
post_id: UUID,
post_title: str,
post_content: str,
base_url: str,
image_url: Optional[str] = None
) -> bool:
"""Send email to customer requesting approval of a post."""
approve_token = generate_token(post_id, "approve")
reject_token = generate_token(post_id, "reject")
approve_url = f"{base_url}/api/email-action/{approve_token}"
reject_url = f"{base_url}/api/email-action/{reject_token}"
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
.header h1 {{ margin: 0; font-size: 20px; }}
.content {{ padding: 24px; }}
.post-preview {{ background: #f8f9fa; border-left: 4px solid #ffc700; padding: 16px; margin: 20px 0; border-radius: 0 8px 8px 0; white-space: pre-wrap; font-size: 14px; line-height: 1.6; color: #333; }}
.buttons {{ display: flex; gap: 12px; margin-top: 24px; }}
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; text-align: center; }}
.btn-approve {{ background: #22c55e; color: white; }}
.btn-reject {{ background: #6b7280; color: white; }}
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Neuer LinkedIn Post zur Freigabe</h1>
</div>
<div class="content">
<p>Hallo,</p>
<p>Ein neuer LinkedIn Post wurde erstellt und wartet auf deine Freigabe:</p>
<p><strong>{post_title}</strong></p>
<div class="post-preview">{post_content}</div>
{f'<img src="{image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin: 16px 0;" />' if image_url else ''}
<p>Bitte entscheide, ob der Post veröffentlicht werden soll:</p>
<div class="buttons">
<a href="{approve_url}" class="btn btn-approve">Freigeben</a>
<a href="{reject_url}" class="btn btn-reject">Nochmal bearbeiten</a>
</div>
</div>
<div class="footer">
Diese Email wurde automatisch generiert. Die Links sind 72 Stunden gültig.
</div>
</div>
</body>
</html>
"""
return send_email(
to_email=to_email,
subject=f"Post zur Freigabe: {post_title}",
html_content=html_content
)
def send_invitation_email(
to_email: str,
company_name: str,
inviter_name: str,
token: str,
base_url: str
) -> bool:
"""Send invitation email to a new employee."""
invite_url = f"{base_url}/invite/{token}"
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
.header h1 {{ margin: 0; font-size: 20px; }}
.content {{ padding: 24px; }}
.company-badge {{ display: inline-block; padding: 8px 16px; border-radius: 20px; background: #ffc700; color: #1a2424; font-weight: 600; margin: 16px 0; }}
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; background: #ffc700; color: #1a2424; margin-top: 16px; }}
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Du wurdest eingeladen!</h1>
</div>
<div class="content">
<p>Hallo,</p>
<p><strong>{inviter_name}</strong> hat dich eingeladen, dem Team beizutreten:</p>
<p class="company-badge">{company_name}</p>
<p>Als Teammitglied kannst du LinkedIn-Posts erstellen, die der Unternehmensstrategie entsprechen.</p>
<a href="{invite_url}" class="btn">Einladung annehmen</a>
<p style="margin-top: 24px; font-size: 14px; color: #666;">
Oder kopiere diesen Link in deinen Browser:<br>
<code style="font-size: 12px; color: #999;">{invite_url}</code>
</p>
</div>
<div class="footer">
Diese Einladung ist 7 Tage gueltig.
</div>
</div>
</body>
</html>
"""
return send_email(
to_email=to_email,
subject=f"Einladung von {company_name}",
html_content=html_content
)
def send_email_verification(
to_email: str,
token: str,
base_url: str
) -> bool:
"""Send email verification link."""
verify_url = f"{base_url}/verify-email/{token}"
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
.header h1 {{ margin: 0; font-size: 20px; }}
.content {{ padding: 24px; }}
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; background: #ffc700; color: #1a2424; margin-top: 16px; }}
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>E-Mail bestaetigen</h1>
</div>
<div class="content">
<p>Hallo,</p>
<p>Bitte bestaetigen deine E-Mail-Adresse, um dein Konto zu aktivieren:</p>
<a href="{verify_url}" class="btn">E-Mail bestaetigen</a>
<p style="margin-top: 24px; font-size: 14px; color: #666;">
Oder kopiere diesen Link in deinen Browser:<br>
<code style="font-size: 12px; color: #999;">{verify_url}</code>
</p>
</div>
<div class="footer">
Dieser Link ist 24 Stunden gueltig. Falls du dich nicht registriert hast, ignoriere diese E-Mail.
</div>
</div>
</body>
</html>
"""
return send_email(
to_email=to_email,
subject="E-Mail-Adresse bestaetigen",
html_content=html_content
)
def send_welcome_email(
to_email: str,
user_name: str,
account_type: str,
base_url: str
) -> bool:
"""Send welcome email after registration/onboarding completion."""
login_url = f"{base_url}/login"
if account_type == "company":
account_type_text = "Unternehmens-Konto"
next_steps = """
<ul>
<li>Lade deine Teammitglieder ein</li>
<li>Verfeinere deine Unternehmensstrategie</li>
<li>Beginne mit der Post-Erstellung</li>
</ul>
"""
elif account_type == "employee":
account_type_text = "Mitarbeiter-Konto"
next_steps = """
<ul>
<li>Recherchiere neue Topics</li>
<li>Erstelle deinen ersten Post</li>
</ul>
"""
else:
account_type_text = "Ghostwriter-Konto"
next_steps = """
<ul>
<li>Recherchiere neue Topics</li>
<li>Erstelle deinen ersten Post</li>
<li>Passe deine Einstellungen an</li>
</ul>
"""
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
.header h1 {{ margin: 0; font-size: 20px; }}
.content {{ padding: 24px; }}
.account-badge {{ display: inline-block; padding: 8px 16px; border-radius: 20px; background: #22c55e; color: white; font-weight: 600; margin: 8px 0; }}
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; background: #ffc700; color: #1a2424; margin-top: 16px; }}
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
ul {{ padding-left: 20px; }}
li {{ margin: 8px 0; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Willkommen bei LinkedIn Posts!</h1>
</div>
<div class="content">
<p>Hallo {user_name},</p>
<p>Dein Konto wurde erfolgreich eingerichtet!</p>
<p class="account-badge">{account_type_text}</p>
<p><strong>Naechste Schritte:</strong></p>
{next_steps}
<a href="{login_url}" class="btn">Zum Dashboard</a>
</div>
<div class="footer">
Viel Erfolg mit deinen LinkedIn-Posts!
</div>
</div>
</body>
</html>
"""
return send_email(
to_email=to_email,
subject="Willkommen bei LinkedIn Posts!",
html_content=html_content
)
def send_employee_removal_email(
to_email: str,
employee_name: str,
company_name: str
) -> bool:
"""Send email to notify an employee they have been removed from the company."""
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%); color: white; padding: 24px; }}
.header h1 {{ margin: 0; font-size: 20px; }}
.content {{ padding: 24px; }}
.company-badge {{ display: inline-block; padding: 8px 16px; border-radius: 20px; background: #f3f4f6; color: #374151; font-weight: 600; margin: 16px 0; }}
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Konto entfernt</h1>
</div>
<div class="content">
<p>Hallo {employee_name},</p>
<p>dein Konto wurde aus dem Unternehmen entfernt:</p>
<p class="company-badge">{company_name}</p>
<p>Dein Zugang wurde deaktiviert und alle zugehoerigen Daten wurden geloescht.</p>
<p>Falls du Fragen hast, wende dich bitte an deinen ehemaligen Arbeitgeber.</p>
</div>
<div class="footer">
Diese E-Mail wurde automatisch generiert.
</div>
</div>
</body>
</html>
"""
return send_email(
to_email=to_email,
subject=f"Dein Konto bei {company_name} wurde entfernt",
html_content=html_content
)
def send_decision_notification_email(
to_email: str,
post_title: str,
decision: str, # "approved" or "rejected"
base_url: str,
post_id: UUID,
image_url: Optional[str] = None
) -> bool:
"""Send email to creator notifying them of the customer's decision."""
post_url = f"{base_url}/posts/{post_id}"
if decision == "approved":
decision_text = "freigegeben"
decision_color = "#22c55e"
action_text = "Der Post wurde in die Spalte 'Freigegeben' verschoben und kann nun im Kalender eingeplant werden."
else:
decision_text = "zur Überarbeitung zurückgeschickt"
decision_color = "#f59e0b"
action_text = "Der Post wurde zurück in die Spalte 'Vorschläge' verschoben. Bitte überarbeite ihn und sende ihn erneut zur Freigabe."
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
.header h1 {{ margin: 0; font-size: 20px; }}
.content {{ padding: 24px; }}
.decision {{ display: inline-block; padding: 8px 16px; border-radius: 20px; font-weight: 600; color: white; background: {decision_color}; margin: 16px 0; }}
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; background: #ffc700; color: #1a2424; margin-top: 16px; }}
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Entscheidung zu deinem Post</h1>
</div>
<div class="content">
<p>Hallo,</p>
<p>Der Kunde hat eine Entscheidung zu deinem Post getroffen:</p>
<p><strong>{post_title}</strong></p>
<p class="decision">{decision_text.upper()}</p>
{f'<img src="{image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin: 16px 0;" />' if image_url else ''}
<p>{action_text}</p>
<a href="{post_url}" class="btn">Post ansehen</a>
</div>
<div class="footer">
Diese Email wurde automatisch generiert.
</div>
</div>
</body>
</html>
"""
return send_email(
to_email=to_email,
subject=f"Post {decision_text}: {post_title}",
html_content=html_content
)

View File

@@ -0,0 +1,314 @@
"""
LinkedIn API service for auto-posting.
Handles:
- UGC Posts API (v2) for posting content
- OAuth token refresh
- Image upload for posts with media
"""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional
from uuid import UUID
import httpx
from src.config import settings
from src.database.client import db
from src.utils.encryption import decrypt_token, encrypt_token
logger = logging.getLogger(__name__)
class LinkedInService:
"""Service for LinkedIn API interactions."""
BASE_URL = "https://api.linkedin.com/v2"
TIMEOUT = 30.0
def __init__(self):
self.db = db
async def post_to_linkedin(
self,
linkedin_account_id: UUID,
text: str,
image_url: Optional[str] = None
) -> Dict:
"""
Post content to LinkedIn using UGC Posts API.
Args:
linkedin_account_id: ID of the linkedin_accounts record
text: Post text content
image_url: Optional image URL from Supabase storage
Returns:
Dict with 'url' key containing the LinkedIn post URL
Raises:
Exception: On API errors
"""
# Get account with decrypted token
account = await self._get_account_with_token(linkedin_account_id)
if not account:
raise ValueError(f"LinkedIn account {linkedin_account_id} not found")
# Check token expiry
if account['token_expires_at'] < datetime.now(timezone.utc):
raise ValueError("Access token expired - refresh needed")
access_token = account['access_token']
linkedin_user_id = account['linkedin_user_id']
# Build post payload
payload = {
"author": f"urn:li:person:{linkedin_user_id}",
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {
"text": text
},
"shareMediaCategory": "NONE"
}
},
"visibility": {
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
}
}
# Handle image upload if provided
if image_url:
try:
media_urn = await self._upload_image(access_token, linkedin_user_id, image_url)
if media_urn:
payload["specificContent"]["com.linkedin.ugc.ShareContent"]["shareMediaCategory"] = "IMAGE"
payload["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [
{
"status": "READY",
"media": media_urn
}
]
except Exception as e:
logger.warning(f"Image upload failed for {linkedin_account_id}: {e}. Posting without image.")
# Post to LinkedIn
async with httpx.AsyncClient(timeout=self.TIMEOUT) as client:
response = await client.post(
f"{self.BASE_URL}/ugcPosts",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"X-Restli-Protocol-Version": "2.0.0"
},
json=payload
)
if response.status_code == 201:
# Success
post_id = response.json().get("id")
await self._update_account_last_used(linkedin_account_id)
# LinkedIn post URL format (best effort)
post_url = f"https://www.linkedin.com/feed/update/{post_id}/" if post_id else None
logger.info(f"Posted to LinkedIn: {post_url}")
return {"url": post_url, "post_id": post_id}
elif response.status_code == 401:
# Token expired or invalid
await self._mark_account_error(
linkedin_account_id,
f"Token expired or invalid (401)"
)
raise Exception("LinkedIn authentication failed - token may be expired")
elif response.status_code == 429:
# Rate limit
await self._mark_account_error(
linkedin_account_id,
"Rate limit exceeded (429)"
)
raise Exception("LinkedIn rate limit exceeded")
else:
# Other error
error_msg = f"LinkedIn API error {response.status_code}: {response.text}"
await self._mark_account_error(linkedin_account_id, error_msg)
raise Exception(error_msg)
async def _upload_image(
self,
access_token: str,
linkedin_user_id: str,
image_url: str
) -> Optional[str]:
"""
Upload image to LinkedIn for use in post.
Returns media URN if successful, None otherwise.
"""
try:
# Step 1: Register upload
register_payload = {
"registerUploadRequest": {
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
"owner": f"urn:li:person:{linkedin_user_id}",
"serviceRelationships": [
{
"relationshipType": "OWNER",
"identifier": "urn:li:userGeneratedContent"
}
]
}
}
async with httpx.AsyncClient(timeout=self.TIMEOUT) as client:
register_response = await client.post(
f"{self.BASE_URL}/assets?action=registerUpload",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
},
json=register_payload
)
if register_response.status_code != 200:
logger.error(f"Image register failed: {register_response.status_code}")
return None
register_data = register_response.json()
upload_url = register_data["value"]["uploadMechanism"]["com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"]["uploadUrl"]
asset_urn = register_data["value"]["asset"]
# Step 2: Download image from Supabase
image_response = await client.get(image_url)
if image_response.status_code != 200:
logger.error(f"Failed to download image from {image_url}")
return None
image_data = image_response.content
# Step 3: Upload to LinkedIn
upload_response = await client.put(
upload_url,
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/octet-stream"
},
content=image_data
)
if upload_response.status_code in [200, 201]:
logger.info(f"Image uploaded successfully: {asset_urn}")
return asset_urn
else:
logger.error(f"Image upload failed: {upload_response.status_code}")
return None
except Exception as e:
logger.error(f"Image upload error: {e}")
return None
async def refresh_access_token(self, linkedin_account_id: UUID) -> bool:
"""
Attempt to refresh the access token using refresh token.
Note: LinkedIn's OAuth 2.0 may not support refresh tokens for all scopes.
Check LinkedIn documentation for current support.
Returns:
True if refresh succeeded, False otherwise
"""
account = await self._get_account_with_token(linkedin_account_id)
if not account or not account.get('refresh_token'):
logger.warning(f"No refresh token for account {linkedin_account_id}")
return False
refresh_token = account['refresh_token']
try:
async with httpx.AsyncClient(timeout=self.TIMEOUT) as client:
response = await client.post(
"https://www.linkedin.com/oauth/v2/accessToken",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": settings.linkedin_client_id,
"client_secret": settings.linkedin_client_secret
},
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
if response.status_code == 200:
token_data = response.json()
new_access_token = token_data["access_token"]
expires_in = token_data.get("expires_in", 5184000) # Default 60 days
# Encrypt and update tokens
encrypted_access = encrypt_token(new_access_token)
new_refresh = token_data.get("refresh_token")
encrypted_refresh = encrypt_token(new_refresh) if new_refresh else account['refresh_token']
await self.db.update_linkedin_account(
linkedin_account_id,
{
"access_token": encrypted_access,
"refresh_token": encrypted_refresh,
"token_expires_at": datetime.now(timezone.utc) + timedelta(seconds=expires_in),
"last_error": None,
"last_error_at": None
}
)
logger.info(f"Token refreshed for account {linkedin_account_id}")
return True
else:
logger.error(f"Token refresh failed: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"Token refresh error: {e}")
return False
async def _get_account_with_token(self, linkedin_account_id: UUID) -> Optional[Dict]:
"""Get account and decrypt tokens."""
account = await self.db.get_linkedin_account_by_id(linkedin_account_id)
if not account:
return None
# Decrypt tokens
account_dict = account.model_dump()
account_dict['access_token'] = decrypt_token(account.access_token)
if account.refresh_token:
account_dict['refresh_token'] = decrypt_token(account.refresh_token)
return account_dict
async def _update_account_last_used(self, linkedin_account_id: UUID):
"""Update last_used_at timestamp."""
await self.db.update_linkedin_account(
linkedin_account_id,
{
"last_used_at": datetime.now(timezone.utc),
"last_error": None,
"last_error_at": None
}
)
async def _mark_account_error(self, linkedin_account_id: UUID, error_msg: str):
"""Mark account with error."""
await self.db.update_linkedin_account(
linkedin_account_id,
{
"last_error": error_msg,
"last_error_at": datetime.now(timezone.utc)
}
)
# Global instance
linkedin_service = LinkedInService()

View File

@@ -0,0 +1,288 @@
"""Background scheduler for handling scheduled posts.
This service runs in the background and:
1. Checks for posts that are due for publishing
2. Marks them as published
3. Sends notification emails to employees
Future extension: Could integrate with LinkedIn API for automatic posting.
"""
import asyncio
from datetime import datetime, timezone
from typing import Optional
from loguru import logger
from src.database.client import DatabaseClient
from src.services.email_service import send_email
class SchedulerService:
"""Background scheduler for post publishing."""
def __init__(self, db: DatabaseClient, check_interval_seconds: int = 60):
"""Initialize the scheduler.
Args:
db: Database client instance
check_interval_seconds: How often to check for due posts (default: 60s)
"""
self.db = db
self.check_interval = check_interval_seconds
self._running = False
self._task: Optional[asyncio.Task] = None
async def start(self):
"""Start the background scheduler."""
if self._running:
logger.warning("Scheduler already running")
return
self._running = True
self._task = asyncio.create_task(self._run_loop())
logger.info(f"Scheduler started (checking every {self.check_interval}s)")
async def stop(self):
"""Stop the background scheduler."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("Scheduler stopped")
async def _run_loop(self):
"""Main scheduler loop."""
while self._running:
try:
await self._process_due_posts()
except Exception as e:
logger.error(f"Scheduler error: {e}")
await asyncio.sleep(self.check_interval)
async def _process_due_posts(self):
"""Process all posts that are due for publishing."""
due_posts = await self.db.get_scheduled_posts_due()
if not due_posts:
return
logger.info(f"Processing {len(due_posts)} due posts")
for post in due_posts:
try:
await self._publish_post(post)
except Exception as e:
logger.error(f"Failed to publish post {post.id}: {e}")
async def _publish_post(self, post):
"""Publish a single post - with LinkedIn API if account linked, otherwise email."""
from uuid import UUID
# Get LinkedIn account for user
linkedin_account = await self.db.get_linkedin_account(post.user_id)
if linkedin_account:
# User has LinkedIn account linked -> Try auto-posting
try:
await self._auto_post_to_linkedin(post, linkedin_account)
return # Success - no need for email fallback
except Exception as e:
logger.error(f"LinkedIn auto-post failed for post {post.id}: {e}")
# Fall through to email notification
# No LinkedIn account or auto-post failed -> Send email notification
await self._publish_post_via_email(post)
async def _auto_post_to_linkedin(self, post, linkedin_account):
"""Auto-post to LinkedIn using linked account."""
from src.services.linkedin_service import linkedin_service
from src.utils.encryption import decrypt_token
# Check token expiry
if linkedin_account.token_expires_at < datetime.now(timezone.utc):
logger.warning(f"LinkedIn token expired for account {linkedin_account.id}")
# Try to refresh token
refreshed = await linkedin_service.refresh_access_token(linkedin_account.id)
if not refreshed:
# Token refresh failed -> Fall back to email
raise Exception("Token expired and refresh failed")
# Post to LinkedIn
result = await linkedin_service.post_to_linkedin(
linkedin_account_id=linkedin_account.id,
text=post.post_content,
image_url=post.image_url
)
# Update post as published with LinkedIn metadata
await self.db.update_generated_post(
post.id,
{
"status": "published",
"published_at": datetime.now(timezone.utc),
"metadata": {
**(post.metadata or {}),
"linkedin_post_url": result.get("url"),
"auto_posted": True,
"posted_at": datetime.now(timezone.utc).isoformat()
}
}
)
logger.info(f"✅ Post {post.id} auto-posted to LinkedIn: {result.get('url')}")
# Send success notification
profile = await self.db.get_profile(post.user_id)
if profile:
await self._send_auto_post_success_notification(post, profile, result)
async def _publish_post_via_email(self, post):
"""Fallback: Mark as published and send email notification."""
# Update post status to published
await self.db.update_generated_post(
post.id,
{
"status": "published",
"published_at": datetime.now(timezone.utc)
}
)
logger.info(f"Post {post.id} marked as published (email notification)")
# Get profile info for notification
profile = await self.db.get_profile(post.user_id)
if not profile:
logger.warning(f"No profile found for post {post.id}")
return
# Send notification email
await self._send_publish_notification(post, profile)
async def _send_publish_notification(self, post, profile):
"""Send email notification that a post is ready to be published.
Future: When LinkedIn API is integrated, this could instead
confirm that the post was automatically published.
"""
# Try to get employee email
recipient_email = profile.creator_email or profile.customer_email
if not recipient_email:
logger.warning(f"No email for profile {profile.id}, skipping notification")
return
subject = "LinkedIn Post bereit zum Veröffentlichen"
display_name = profile.display_name or "dort"
html_content = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #0a66c2;">Dein LinkedIn Post ist bereit! 🚀</h2>
<p>Hallo {display_name},</p>
<p>Dein geplanter LinkedIn Post ist jetzt zur Veröffentlichung bereit:</p>
<div style="background: #f3f4f6; border-radius: 8px; padding: 20px; margin: 20px 0;">
<p style="font-weight: bold; margin-bottom: 10px;">Thema: {post.topic_title}</p>
<div style="background: white; border-radius: 4px; padding: 15px; white-space: pre-wrap; font-size: 14px; line-height: 1.5;">
{post.post_content[:500]}{'...' if len(post.post_content) > 500 else ''}
</div>
{f'<img src="{post.image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin-top: 15px;" />' if post.image_url else ''}
</div>
<p><strong>Nächste Schritte:</strong></p>
<ol>
<li>Öffne LinkedIn</li>
<li>Erstelle einen neuen Post</li>
<li>Kopiere den Text oben und füge ihn ein</li>
<li>Veröffentliche den Post</li>
</ol>
<p style="color: #666; font-size: 12px; margin-top: 30px;">
Diese Nachricht wurde automatisch generiert.
</p>
</div>
"""
try:
send_email(
to_email=recipient_email,
subject=subject,
html_content=html_content
)
logger.info(f"Publish notification sent to {recipient_email}")
except Exception as e:
logger.error(f"Failed to send publish notification: {e}")
async def _send_auto_post_success_notification(self, post, profile, result):
"""Send success email after auto-posting to LinkedIn."""
recipient_email = profile.creator_email or profile.customer_email
if not recipient_email:
logger.warning(f"No email for profile {profile.id}, skipping success notification")
return
subject = "✅ LinkedIn Post automatisch veröffentlicht"
display_name = profile.display_name or "dort"
linkedin_url = result.get('url', 'https://www.linkedin.com/feed/')
html_content = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #0a66c2;">Post erfolgreich veröffentlicht! 🎉</h2>
<p>Hallo {display_name},</p>
<p>Dein geplanter LinkedIn Post wurde automatisch veröffentlicht:</p>
<div style="background: #f3f4f6; border-radius: 8px; padding: 20px; margin: 20px 0;">
<p style="font-weight: bold; margin-bottom: 10px;">Thema: {post.topic_title}</p>
<div style="background: white; border-radius: 4px; padding: 15px; white-space: pre-wrap; font-size: 14px; line-height: 1.5;">
{post.post_content[:500]}{'...' if len(post.post_content) > 500 else ''}
</div>
{f'<img src="{post.image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin-top: 15px;" />' if post.image_url else ''}
</div>
<p>
<a href="{linkedin_url}" style="display: inline-block; background: #0a66c2; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">
Auf LinkedIn ansehen →
</a>
</p>
<p style="color: #666; margin-top: 30px;">
Dein Post wurde automatisch über dein verbundenes LinkedIn-Konto veröffentlicht.
</p>
<p style="color: #666; font-size: 12px; margin-top: 30px;">
Diese Nachricht wurde automatisch generiert.
</p>
</div>
"""
try:
send_email(
to_email=recipient_email,
subject=subject,
html_content=html_content
)
logger.info(f"Auto-post success notification sent to {recipient_email}")
except Exception as e:
logger.error(f"Failed to send success notification: {e}")
# Global scheduler instance
_scheduler: Optional[SchedulerService] = None
def get_scheduler() -> Optional[SchedulerService]:
"""Get the global scheduler instance."""
return _scheduler
def init_scheduler(db: DatabaseClient, check_interval: int = 60) -> SchedulerService:
"""Initialize and return the global scheduler instance."""
global _scheduler
_scheduler = SchedulerService(db, check_interval)
return _scheduler

View File

@@ -0,0 +1,103 @@
"""Supabase Storage service for post image uploads."""
import asyncio
import uuid
from typing import Optional
from loguru import logger
from src.config import settings
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
BUCKET_NAME = "post-images"
CONTENT_TYPE_EXTENSIONS = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
class StorageService:
"""Handles image uploads to Supabase Storage."""
def __init__(self):
from supabase import create_client
key = settings.supabase_service_role_key or settings.supabase_key
self.client = create_client(settings.supabase_url, key)
self._bucket_ensured = False
def _ensure_bucket(self):
"""Create the post-images bucket if it doesn't exist."""
if self._bucket_ensured:
return
try:
self.client.storage.get_bucket(BUCKET_NAME)
except Exception:
try:
self.client.storage.create_bucket(
BUCKET_NAME,
options={"public": True}
)
logger.info(f"Created storage bucket: {BUCKET_NAME}")
except Exception as e:
if "already exists" in str(e).lower() or "Duplicate" in str(e):
pass
else:
logger.error(f"Failed to create bucket: {e}")
raise
self._bucket_ensured = True
async def upload_image(
self,
file_content: bytes,
content_type: str,
user_id: str,
) -> str:
"""Upload an image to Supabase Storage.
Returns the public URL of the uploaded image.
"""
if content_type not in ALLOWED_CONTENT_TYPES:
raise ValueError(f"Unzulässiger Dateityp: {content_type}. Erlaubt: JPEG, PNG, GIF, WebP")
if len(file_content) > MAX_FILE_SIZE:
raise ValueError(f"Datei zu groß (max. {MAX_FILE_SIZE // 1024 // 1024} MB)")
ext = CONTENT_TYPE_EXTENSIONS[content_type]
file_name = f"{user_id}/{uuid.uuid4()}.{ext}"
def _upload():
self._ensure_bucket()
self.client.storage.from_(BUCKET_NAME).upload(
path=file_name,
file=file_content,
file_options={"content-type": content_type},
)
await asyncio.to_thread(_upload)
public_url = f"{settings.supabase_url}/storage/v1/object/public/{BUCKET_NAME}/{file_name}"
logger.info(f"Uploaded image: {file_name}")
return public_url
async def delete_image(self, image_url: str) -> None:
"""Delete an image from Supabase Storage by its public URL."""
prefix = f"{settings.supabase_url}/storage/v1/object/public/{BUCKET_NAME}/"
if not image_url.startswith(prefix):
logger.warning(f"Cannot delete image, URL doesn't match bucket: {image_url}")
return
file_path = image_url[len(prefix):]
def _delete():
self._ensure_bucket()
self.client.storage.from_(BUCKET_NAME).remove([file_path])
await asyncio.to_thread(_delete)
logger.info(f"Deleted image: {file_path}")
# Global singleton
storage = StorageService()

View File

@@ -717,7 +717,7 @@ class StatusScreen(Screen):
output = "" output = ""
for customer in customers: for customer in customers:
status = await orchestrator.get_customer_status(customer.id) status = await orchestrator.get_user_status(customer.id)
output += f"[bold cyan]╔═══ {customer.name} ═══╗[/]\n" output += f"[bold cyan]╔═══ {customer.name} ═══╗[/]\n"
output += f"[bold]Customer ID:[/] {customer.id}\n" output += f"[bold]Customer ID:[/] {customer.id}\n"

1
src/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Utility modules."""

38
src/utils/encryption.py Normal file
View File

@@ -0,0 +1,38 @@
"""Token encryption utilities using Fernet symmetric encryption."""
from cryptography.fernet import Fernet
from src.config import settings
def encrypt_token(token: str) -> str:
"""
Encrypt a token string using Fernet symmetric encryption.
Args:
token: The plaintext token to encrypt
Returns:
Base64-encoded encrypted token string
"""
if not settings.encryption_key:
raise ValueError("ENCRYPTION_KEY not configured in environment")
cipher = Fernet(settings.encryption_key.encode())
return cipher.encrypt(token.encode()).decode()
def decrypt_token(encrypted: str) -> str:
"""
Decrypt a Fernet-encrypted token string.
Args:
encrypted: The base64-encoded encrypted token
Returns:
The decrypted plaintext token
"""
if not settings.encryption_key:
raise ValueError("ENCRYPTION_KEY not configured in environment")
cipher = Fernet(settings.encryption_key.encode())
return cipher.decrypt(encrypted.encode()).decode()

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,47 @@
"""FastAPI web frontend for LinkedIn Post Creation System.""" """FastAPI web frontend for LinkedIn Post Creation System."""
from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from loguru import logger
from src.config import settings from src.config import settings
from src.web.admin import admin_router from src.web.admin import admin_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifecycle - startup and shutdown."""
# Startup
logger.info("Starting LinkedIn Post Creation System...")
# Initialize and start scheduler if enabled
scheduler = None
if settings.user_frontend_enabled:
try:
from src.database.client import DatabaseClient
from src.services.scheduler_service import init_scheduler
db = DatabaseClient()
scheduler = init_scheduler(db, check_interval=60) # Check every 60 seconds
await scheduler.start()
logger.info("Scheduler service started")
except Exception as e:
logger.error(f"Failed to start scheduler: {e}")
yield # Application runs here
# Shutdown
logger.info("Shutting down LinkedIn Post Creation System...")
if scheduler:
await scheduler.stop()
logger.info("Scheduler service stopped")
# Setup # Setup
app = FastAPI(title="LinkedIn Post Creation System") app = FastAPI(title="LinkedIn Post Creation System", lifespan=lifespan)
# Static files # Static files
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static") app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin - LinkedIn Posts{% endblock %}</title> <title>{% block title %}Admin Panel{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script> <script>
tailwind.config = { tailwind.config = {
@@ -26,7 +26,6 @@
body { background-color: #3d4848; } body { background-color: #3d4848; }
.nav-link.active { background-color: #ffc700; color: #2d3838; } .nav-link.active { background-color: #ffc700; color: #2d3838; }
.nav-link.active svg { stroke: #2d3838; } .nav-link.active svg { stroke: #2d3838; }
.post-content { white-space: pre-wrap; word-wrap: break-word; }
.btn-primary { background-color: #ffc700; color: #2d3838; } .btn-primary { background-color: #ffc700; color: #2d3838; }
.btn-primary:hover { background-color: #e6b300; } .btn-primary:hover { background-color: #e6b300; }
.sidebar-bg { background-color: #2d3838; } .sidebar-bg { background-color: #2d3838; }
@@ -55,33 +54,19 @@
</div> </div>
<nav class="flex-1 p-4 space-y-2"> <nav class="flex-1 p-4 space-y-2">
<a href="/admin" 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 == 'home' %}active{% endif %}"> <a href="/admin" 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 == 'users' %}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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></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="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>
Dashboard Nutzerverwaltung
</a> </a>
<a href="/admin/customers/new" 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 == 'new_customer' %}active{% endif %}"> <a href="/admin/statistics" 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 == 'statistics' %}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="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>
Neuer Kunde
</a>
<a href="/admin/research" 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 == 'research' %}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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
Research Topics
</a>
<a href="/admin/create" 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 == 'create' %}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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Post erstellen
</a>
<a href="/admin/posts" 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 == 'posts' %}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="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>
Alle Posts
</a>
<a href="/admin/scraped-posts" 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 == 'scraped_posts' %}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="/admin/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> <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 Statistiken
</a>
<a href="/admin/license-keys" 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 == 'license_keys' %}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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
Lizenzschlüssel
</a> </a>
</nav> </nav>

View File

@@ -1,539 +0,0 @@
{% extends "base.html" %}
{% block title %}Post erstellen - LinkedIn Posts{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Post erstellen</h1>
<p class="text-gray-400">Generiere einen neuen LinkedIn Post mit AI</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left: Form -->
<div>
<form id="createPostForm" class="card-bg rounded-xl border p-6 space-y-6">
<!-- Customer Selection -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
<option value="">-- Kunde wählen --</option>
{% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
{% endfor %}
</select>
</div>
<!-- Post Type Selection -->
<div id="postTypeSelectionArea" class="hidden">
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ auswählen (optional)</label>
<div id="postTypeCards" class="flex flex-wrap gap-2 mb-2">
<!-- Post type cards will be loaded here -->
</div>
<input type="hidden" id="selectedPostTypeId" value="">
</div>
<!-- Topic Selection -->
<div id="topicSelectionArea" class="hidden">
<label class="block text-sm font-medium text-gray-300 mb-2">Topic auswählen</label>
<div id="topicsList" class="space-y-2 max-h-64 overflow-y-auto">
<p class="text-gray-500">Lade Topics...</p>
</div>
</div>
<!-- Custom Topic -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<span>Oder eigenes Topic eingeben</span>
</label>
<div class="space-y-3">
<input type="text" id="customTopicTitle" placeholder="Topic Titel" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
<textarea id="customTopicFact" rows="3" placeholder="Fakt / Kernaussage zum Topic..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
<input type="text" id="customTopicSource" placeholder="Quelle (optional)" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
</div>
</div>
<!-- Progress Area -->
<div id="progressArea" class="hidden">
<div class="bg-brand-bg rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span id="progressMessage" class="text-gray-300">Starte Post-Erstellung...</span>
<span id="progressPercent" class="text-gray-400">0%</span>
</div>
<div class="w-full bg-brand-bg-dark rounded-full h-2">
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div id="iterationInfo" class="mt-2 text-sm text-gray-400"></div>
</div>
</div>
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Post generieren
</button>
</form>
{% if not customers %}
<div class="mt-4 bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/admin/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
</div>
{% endif %}
</div>
<!-- Right: Result -->
<div>
<div id="resultArea" class="card-bg rounded-xl border p-6">
<h3 class="text-lg font-semibold text-white mb-4">Generierter Post</h3>
<!-- Live Versions Display -->
<div id="liveVersions" class="hidden space-y-4 mb-6">
<div class="flex items-center gap-2 mb-2">
<span class="text-sm text-gray-400">Live-Vorschau der Iterationen:</span>
</div>
<div id="versionsContainer" class="space-y-4"></div>
</div>
<div id="postResult">
<p class="text-gray-400">Wähle einen Kunden und ein Topic, dann klicke auf "Post generieren"...</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const form = document.getElementById('createPostForm');
const customerSelect = document.getElementById('customerSelect');
const topicSelectionArea = document.getElementById('topicSelectionArea');
const topicsList = document.getElementById('topicsList');
const submitBtn = document.getElementById('submitBtn');
const progressArea = document.getElementById('progressArea');
const progressBar = document.getElementById('progressBar');
const progressMessage = document.getElementById('progressMessage');
const progressPercent = document.getElementById('progressPercent');
const iterationInfo = document.getElementById('iterationInfo');
const postResult = document.getElementById('postResult');
const liveVersions = document.getElementById('liveVersions');
const versionsContainer = document.getElementById('versionsContainer');
const postTypeSelectionArea = document.getElementById('postTypeSelectionArea');
const postTypeCards = document.getElementById('postTypeCards');
const selectedPostTypeIdInput = document.getElementById('selectedPostTypeId');
let selectedTopic = null;
let currentVersionIndex = 0;
let currentPostTypes = [];
let currentTopics = [];
function renderVersions(versions, feedbackList) {
if (!versions || versions.length === 0) {
liveVersions.classList.add('hidden');
return;
}
liveVersions.classList.remove('hidden');
// Build version tabs and content
let html = `
<div class="flex gap-2 mb-4 flex-wrap">
${versions.map((_, i) => `
<button onclick="showVersion(${i})" id="versionTab${i}"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
${i === currentVersionIndex ? 'bg-brand-highlight text-brand-bg-dark' : 'bg-brand-bg text-gray-300 hover:bg-brand-bg-light'}">
V${i + 1}
${feedbackList[i] ? `<span class="ml-1 text-xs opacity-75">(${feedbackList[i].overall_score || '?'})</span>` : ''}
</button>
`).join('')}
</div>
`;
// Show current version
const currentVersion = versions[currentVersionIndex];
const currentFeedback = feedbackList[currentVersionIndex];
html += `
<div class="grid grid-cols-1 ${currentFeedback ? 'lg:grid-cols-2' : ''} gap-4">
<div class="bg-brand-bg/50 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-300">Version ${currentVersionIndex + 1}</span>
${currentFeedback ? `
<span class="px-2 py-0.5 text-xs rounded ${currentFeedback.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
${currentFeedback.approved ? 'Approved' : `Score: ${currentFeedback.overall_score}/100`}
</span>
` : '<span class="text-xs text-gray-500">Wird bewertet...</span>'}
</div>
<pre class="whitespace-pre-wrap text-gray-200 font-sans text-sm max-h-96 overflow-y-auto">${currentVersion}</pre>
</div>
${currentFeedback ? `
<div class="bg-brand-bg/30 rounded-lg p-4 border border-brand-bg-light">
<span class="text-sm font-medium text-gray-300 block mb-2">Kritik</span>
<p class="text-sm text-gray-400 mb-3">${currentFeedback.feedback || 'Keine Kritik'}</p>
${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? `
<div class="mt-2">
<span class="text-xs font-medium text-gray-400">Verbesserungen:</span>
<ul class="mt-1 space-y-1">
${currentFeedback.improvements.map(imp => `
<li class="text-xs text-gray-500 flex items-start gap-1">
<span class="text-yellow-500">•</span> ${imp}
</li>
`).join('')}
</ul>
</div>
` : ''}
${currentFeedback.scores ? `
<div class="mt-3 pt-3 border-t border-brand-bg-light">
<div class="grid grid-cols-3 gap-2 text-xs">
<div class="text-center">
<div class="text-gray-500">Authentizität</div>
<div class="font-medium text-gray-300">${currentFeedback.scores.authenticity_and_style || '?'}/40</div>
</div>
<div class="text-center">
<div class="text-gray-500">Content</div>
<div class="font-medium text-gray-300">${currentFeedback.scores.content_quality || '?'}/35</div>
</div>
<div class="text-center">
<div class="text-gray-500">Technik</div>
<div class="font-medium text-gray-300">${currentFeedback.scores.technical_execution || '?'}/25</div>
</div>
</div>
</div>
` : ''}
</div>
` : ''}
</div>
`;
versionsContainer.innerHTML = html;
}
function showVersion(index) {
currentVersionIndex = index;
// Get cached versions from progress store
const cachedData = window.lastProgressData;
if (cachedData) {
renderVersions(cachedData.versions, cachedData.feedback_list);
}
}
// Load topics and post types when customer is selected
customerSelect.addEventListener('change', async () => {
const customerId = customerSelect.value;
selectedPostTypeIdInput.value = '';
if (!customerId) {
topicSelectionArea.classList.add('hidden');
postTypeSelectionArea.classList.add('hidden');
return;
}
topicSelectionArea.classList.remove('hidden');
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
// Load post types
try {
const ptResponse = await fetch(`/admin/api/customers/${customerId}/post-types`);
const ptData = await ptResponse.json();
if (ptData.post_types && ptData.post_types.length > 0) {
currentPostTypes = ptData.post_types;
postTypeSelectionArea.classList.remove('hidden');
postTypeCards.innerHTML = `
<button type="button" onclick="selectPostTypeForCreate('')" id="ptc_all"
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
Alle Typen
</button>
` + ptData.post_types.map(pt => `
<button type="button" onclick="selectPostTypeForCreate('${pt.id}')" id="ptc_${pt.id}"
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
${pt.name}
${pt.has_analysis ? '<span class="ml-1 text-green-400 text-xs">*</span>' : ''}
</button>
`).join('');
} else {
postTypeSelectionArea.classList.add('hidden');
}
} catch (error) {
console.error('Failed to load post types:', error);
postTypeSelectionArea.classList.add('hidden');
}
// Load topics
try {
const response = await fetch(`/admin/api/customers/${customerId}/topics`);
const data = await response.json();
if (data.topics && data.topics.length > 0) {
renderTopicsList(data);
} else {
// No topics available - show helpful message
let message = '';
if (data.used_count > 0) {
message = `<div class="text-center py-4">
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
<a href="/admin/research" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
</div>`;
} else {
message = `<div class="text-center py-4">
<p class="text-gray-400 mb-2">Keine Topics gefunden.</p>
<a href="/admin/research" class="text-brand-highlight hover:underline">Recherche starten</a>
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
</div>`;
}
topicsList.innerHTML = message;
}
} catch (error) {
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
}
});
// Clear selected topic when custom topic is entered
['customTopicTitle', 'customTopicFact', 'customTopicSource'].forEach(id => {
document.getElementById(id).addEventListener('input', () => {
selectedTopic = null;
document.querySelectorAll('input[name="topic"]').forEach(radio => radio.checked = false);
});
});
function selectPostTypeForCreate(typeId) {
selectedPostTypeIdInput.value = typeId;
// Update card styles
document.querySelectorAll('[id^="ptc_"]').forEach(card => {
if (card.id === `ptc_${typeId}` || (typeId === '' && card.id === 'ptc_all')) {
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
} else {
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
}
});
// Optionally reload topics filtered by post type
const customerId = customerSelect.value;
if (customerId) {
loadTopicsForPostType(customerId, typeId);
}
}
async function loadTopicsForPostType(customerId, postTypeId) {
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
try {
let url = `/api/customers/${customerId}/topics`;
if (postTypeId) {
url += `?post_type_id=${postTypeId}`;
}
const response = await fetch(url);
const data = await response.json();
if (data.topics && data.topics.length > 0) {
renderTopicsList(data);
} else {
let message = '';
if (data.used_count > 0) {
message = `<div class="text-center py-4">
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
<a href="/admin/research" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
</div>`;
} else {
message = `<div class="text-center py-4">
<p class="text-gray-400 mb-2">Keine Topics gefunden${postTypeId ? ' für diesen Post-Typ' : ''}.</p>
<a href="/admin/research" class="text-brand-highlight hover:underline">Recherche starten</a>
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
</div>`;
}
topicsList.innerHTML = message;
}
} catch (error) {
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
}
}
function renderTopicsList(data) {
// Store topics in global array for safe access
currentTopics = data.topics;
// Reset selected topic when list is re-rendered
selectedTopic = null;
let statsHtml = '';
if (data.used_count > 0) {
statsHtml = `<p class="text-xs text-gray-500 mb-3">${data.available_count} verfügbar · ${data.used_count} bereits verwendet</p>`;
}
topicsList.innerHTML = statsHtml + data.topics.map((topic, i) => `
<label class="flex items-start gap-3 p-3 bg-brand-bg/50 rounded-lg cursor-pointer hover:bg-brand-bg transition-colors border border-transparent hover:border-brand-highlight/30">
<input type="radio" name="topic" value="${i}" class="mt-1 text-brand-highlight" data-topic-index="${i}">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">${escapeHtml(topic.category || 'Topic')}</span>
${topic.target_post_type_id ? `<span class="text-xs text-gray-500">Typ-spezifisch</span>` : ''}
${topic.source ? `<span class="text-xs text-gray-500">🔗 ${escapeHtml(topic.source.substring(0, 30))}${topic.source.length > 30 ? '...' : ''}</span>` : ''}
</div>
<p class="font-medium text-white">${escapeHtml(topic.title)}</p>
${topic.angle ? `<p class="text-xs text-brand-highlight/80 mt-1">→ ${escapeHtml(topic.angle)}</p>` : ''}
${topic.hook_idea ? `<p class="text-sm text-gray-300 mt-2 italic border-l-2 border-brand-highlight/30 pl-2">"${escapeHtml(topic.hook_idea.substring(0, 120))}${topic.hook_idea.length > 120 ? '...' : ''}"</p>` : ''}
${topic.key_facts && topic.key_facts.length > 0 ? `
<div class="mt-2 flex flex-wrap gap-1">
${topic.key_facts.slice(0, 2).map(f => `<span class="text-xs bg-brand-bg-dark px-2 py-0.5 rounded text-gray-400">📊 ${escapeHtml(f.substring(0, 40))}${f.length > 40 ? '...' : ''}</span>`).join('')}
</div>
` : (topic.fact ? `<p class="text-sm text-gray-400 mt-1">${escapeHtml(topic.fact.substring(0, 100))}...</p>` : '')}
${topic.why_this_person ? `<p class="text-xs text-gray-500 mt-2">💡 ${escapeHtml(topic.why_this_person.substring(0, 80))}${topic.why_this_person.length > 80 ? '...' : ''}</p>` : ''}
</div>
</label>
`).join('');
// Add event listeners to radio buttons
document.querySelectorAll('input[name="topic"]').forEach(radio => {
radio.addEventListener('change', () => {
const index = parseInt(radio.dataset.topicIndex, 10);
selectedTopic = currentTopics[index];
// Clear custom topic fields
document.getElementById('customTopicTitle').value = '';
document.getElementById('customTopicFact').value = '';
document.getElementById('customTopicSource').value = '';
});
});
}
// Helper function to escape HTML special characters
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const customerId = customerSelect.value;
if (!customerId) {
alert('Bitte wähle einen Kunden aus.');
return;
}
// Get topic (either selected or custom)
let topic;
const customTitle = document.getElementById('customTopicTitle').value.trim();
const customFact = document.getElementById('customTopicFact').value.trim();
if (customTitle && customFact) {
topic = {
title: customTitle,
fact: customFact,
source: document.getElementById('customTopicSource').value.trim() || null,
category: 'Custom'
};
} else if (selectedTopic) {
topic = selectedTopic;
} else {
alert('Bitte wähle ein Topic aus oder gib ein eigenes ein.');
return;
}
submitBtn.disabled = true;
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" 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> Generiert...';
progressArea.classList.remove('hidden');
postResult.innerHTML = '<p class="text-gray-400">Post wird generiert...</p>';
const formData = new FormData();
formData.append('customer_id', customerId);
formData.append('topic_json', JSON.stringify(topic));
if (selectedPostTypeIdInput.value) {
formData.append('post_type_id', selectedPostTypeIdInput.value);
}
try {
const response = await fetch('/admin/api/posts', {
method: 'POST',
body: formData
});
const data = await response.json();
const taskId = data.task_id;
currentVersionIndex = 0;
window.lastProgressData = null;
const pollInterval = setInterval(async () => {
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
const status = await statusResponse.json();
progressBar.style.width = `${status.progress}%`;
progressPercent.textContent = `${status.progress}%`;
progressMessage.textContent = status.message;
if (status.iteration !== undefined) {
iterationInfo.textContent = `Iteration ${status.iteration}/${status.max_iterations}`;
}
// Update live versions display
if (status.versions && status.versions.length > 0) {
window.lastProgressData = status;
// Auto-select latest version
if (status.versions.length > currentVersionIndex + 1) {
currentVersionIndex = status.versions.length - 1;
}
renderVersions(status.versions, status.feedback_list || []);
postResult.innerHTML = '<p class="text-gray-400">Siehe Live-Vorschau oben...</p>';
}
if (status.status === 'completed') {
clearInterval(pollInterval);
progressArea.classList.add('hidden');
submitBtn.disabled = false;
submitBtn.innerHTML = '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
// Keep live versions visible but update header
const result = status.result;
postResult.innerHTML = `
<div class="space-y-4">
<div class="flex items-center gap-2 text-sm flex-wrap">
<span class="px-2 py-1 rounded ${result.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
${result.approved ? 'Approved' : 'Review needed'}
</span>
<span class="text-gray-400">Score: ${result.final_score}/100</span>
<span class="text-gray-400">Iterations: ${result.iterations}</span>
</div>
<div class="text-sm text-gray-400 mb-2">Finaler Post:</div>
<div class="bg-brand-bg/50 rounded-lg p-4">
<pre class="whitespace-pre-wrap text-gray-200 font-sans">${result.final_post}</pre>
</div>
<div class="flex gap-2">
<button onclick="copyPost()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
In Zwischenablage kopieren
</button>
<button onclick="toggleVersions()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
Versionen ${liveVersions.classList.contains('hidden') ? 'anzeigen' : 'ausblenden'}
</button>
</div>
</div>
`;
} else if (status.status === 'error') {
clearInterval(pollInterval);
progressArea.classList.add('hidden');
submitBtn.disabled = false;
submitBtn.innerHTML = '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
postResult.innerHTML = `<p class="text-red-400">Fehler: ${status.message}</p>`;
}
}, 1000);
} catch (error) {
progressArea.classList.add('hidden');
submitBtn.disabled = false;
submitBtn.innerHTML = '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
postResult.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
}
});
function copyPost() {
const postText = document.querySelector('#postResult pre').textContent;
navigator.clipboard.writeText(postText).then(() => {
alert('Post in Zwischenablage kopiert!');
});
}
function toggleVersions() {
liveVersions.classList.toggle('hidden');
}
</script>
{% endblock %}

View File

@@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dashboard - LinkedIn Posts{% endblock %} {% block title %}Nutzerverwaltung - Admin{% endblock %}
{% block content %} {% block content %}
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1> <h1 class="text-3xl font-bold text-white mb-2">Nutzerverwaltung</h1>
<p class="text-gray-400">Willkommen zum LinkedIn Post Creation System</p> <p class="text-gray-400">Alle Benutzer und Firmen verwalten</p>
</div> </div>
{% if error %} {% if error %}
@@ -13,85 +13,277 @@
</div> </div>
{% endif %} {% endif %}
<!-- Stats --> <!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="card-bg rounded-xl border p-6"> <div class="card-bg rounded-xl border p-6">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-brand-highlight" 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> <svg class="w-6 h-6 text-brand-highlight" 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> </div>
<div> <div>
<p class="text-gray-400 text-sm">Kunden</p> <p class="text-gray-400 text-sm">Gesamt Nutzer</p>
<p class="text-2xl font-bold text-white">{{ customers_count or 0 }}</p> <p class="text-2xl font-bold text-white">{{ total_users }}</p>
</div>
</div>
</div>
<div class="card-bg rounded-xl border p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-600/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
</div>
<div>
<p class="text-gray-400 text-sm">Firmen</p>
<p class="text-2xl font-bold text-white">{{ total_companies }}</p>
</div> </div>
</div> </div>
</div> </div>
<div class="card-bg rounded-xl border p-6"> <div class="card-bg rounded-xl border p-6">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="w-12 h-12 bg-green-600/20 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-green-600/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg> <svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
</div> </div>
<div> <div>
<p class="text-gray-400 text-sm">Generierte Posts</p> <p class="text-gray-400 text-sm">Ghostwriter</p>
<p class="text-2xl font-bold text-white">{{ total_posts or 0 }}</p> <p class="text-2xl font-bold text-white">{{ total_ghostwriters }}</p>
</div> </div>
</div> </div>
</div> </div>
<div class="card-bg rounded-xl border p-6"> <div class="card-bg rounded-xl border p-6">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-purple-600/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg> <svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"/></svg>
</div> </div>
<div> <div>
<p class="text-gray-400 text-sm">AI Agents</p> <p class="text-gray-400 text-sm">Mitarbeiter</p>
<p class="text-2xl font-bold text-white">5</p> <p class="text-2xl font-bold text-white">{{ total_employees }}</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Quick Actions --> <!-- Search -->
<div class="card-bg rounded-xl border p-6"> <div class="mb-6">
<h2 class="text-xl font-semibold text-white mb-4">Schnellaktionen</h2> <input type="text" id="searchInput" placeholder="Suche nach Name oder E-Mail..."
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> class="w-full max-w-md input-bg border rounded-lg px-4 py-2 text-white placeholder-gray-400"
<a href="/admin/customers/new" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors"> oninput="filterUsers()">
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center"> </div>
<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 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
<!-- Tabs -->
<div class="flex gap-2 mb-6">
<button onclick="showTab('companies')" id="tab-companies"
class="px-5 py-2 rounded-lg font-medium transition-colors btn-primary">
Firmen ({{ total_companies }})
</button>
<button onclick="showTab('ghostwriters')" id="tab-ghostwriters"
class="px-5 py-2 rounded-lg font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg">
Ghostwriter ({{ total_ghostwriters }})
</button>
</div>
<!-- Companies Tab -->
<div id="content-companies" class="space-y-4">
{% for cd in company_data %}
<div class="card-bg rounded-xl border overflow-hidden user-card" data-search="{{ cd.company.name|lower }} {{ cd.owner.email|lower if cd.owner else '' }} {{ cd.owner.display_name|lower if cd.owner and cd.owner.display_name else '' }}">
<!-- Company Header (clickable to expand) -->
<div class="p-5 cursor-pointer hover:bg-brand-bg-light/50 transition-colors" onclick="toggleCompany('company-{{ cd.company.id }}')">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-blue-600/30 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
</div>
<div>
<h3 class="text-lg font-semibold text-white">{{ cd.company.name }}</h3>
<p class="text-sm text-gray-400">
Owner: {{ cd.owner.display_name or cd.owner.email if cd.owner else 'Unbekannt' }}
| {{ cd.employee_count }} Mitarbeiter
</p>
</div>
</div>
<div class="flex items-center gap-3">
{% if cd.owner %}
<a href="/admin/impersonate/{{ cd.owner.id }}" class="px-3 py-1.5 text-xs bg-brand-highlight/20 text-brand-highlight rounded-lg hover:bg-brand-highlight/30 transition-colors"
title="Als Owner einloggen" onclick="event.stopPropagation()">
Impersonate
</a>
{% endif %}
<svg class="w-5 h-5 text-gray-400 transition-transform" id="chevron-company-{{ cd.company.id }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
</div> </div>
<div> </div>
<p class="font-medium text-white">Neuer Kunde</p>
<p class="text-sm text-gray-400">Setup starten</p> <!-- Expandable Details -->
<div id="company-{{ cd.company.id }}" class="hidden border-t border-gray-600">
<!-- Owner -->
{% if cd.owner %}
<div class="px-5 py-3 bg-brand-bg/30 border-b border-gray-600/50">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xs font-medium px-2 py-0.5 bg-blue-600/30 text-blue-300 rounded">OWNER</span>
<span class="text-sm text-white">{{ cd.owner.display_name or cd.owner.email }}</span>
<span class="text-xs text-gray-400">{{ cd.owner.email }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">{{ cd.owner.onboarding_status.value if cd.owner.onboarding_status and cd.owner.onboarding_status.value else cd.owner.onboarding_status }}</span>
<button onclick="deleteUser('{{ cd.owner.id }}', '{{ cd.owner.display_name or cd.owner.email }}')"
class="text-red-400 hover:text-red-300 p-1" title="Nutzer löschen">
<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="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>
</div> </div>
</a> {% endif %}
<a href="/admin/research" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center"> <!-- Employees -->
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> {% for emp in cd.employees %}
<div class="px-5 py-3 border-b border-gray-600/30 hover:bg-brand-bg/20">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xs font-medium px-2 py-0.5 bg-purple-600/30 text-purple-300 rounded">EMPLOYEE</span>
<span class="text-sm text-white">{{ emp.display_name or emp.email }}</span>
<span class="text-xs text-gray-400">{{ emp.email }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">{{ emp.onboarding_status.value if emp.onboarding_status and emp.onboarding_status.value else emp.onboarding_status }}</span>
<a href="/admin/impersonate/{{ emp.id }}" class="text-brand-highlight hover:text-brand-highlight-dark p-1" title="Als Mitarbeiter einloggen">
<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="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"/></svg>
</a>
<button onclick="deleteUser('{{ emp.id }}', '{{ emp.display_name or emp.email }}')"
class="text-red-400 hover:text-red-300 p-1" title="Nutzer löschen">
<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="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>
</div> </div>
<div> {% endfor %}
<p class="font-medium text-white">Research</p>
<p class="text-sm text-gray-400">Topics finden</p> {% if cd.employees|length == 0 %}
<div class="px-5 py-3 text-sm text-gray-500 italic">Keine Mitarbeiter</div>
{% endif %}
</div>
</div>
{% endfor %}
{% if company_data|length == 0 %}
<div class="card-bg rounded-xl border p-8 text-center text-gray-400">
Keine Firmen vorhanden
</div>
{% endif %}
</div>
<!-- Ghostwriters Tab -->
<div id="content-ghostwriters" class="hidden space-y-3">
{% for gw in ghostwriters %}
{% set profile = gw %}
<div class="card-bg rounded-xl border p-5 user-card" data-search="{{ gw.display_name|lower if gw.display_name else '' }} {{ gw.email|lower }}">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-green-600/30 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
</div>
<div>
<h3 class="text-white font-medium">{{ gw.display_name or gw.email }}</h3>
<p class="text-sm text-gray-400">
{{ gw.email }}
| Status: {{ gw.onboarding_status.value if gw.onboarding_status and gw.onboarding_status.value else gw.onboarding_status }}
</p>
</div>
</div> </div>
</a> <div class="flex items-center gap-2">
<a href="/admin/create" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors"> <a href="/admin/impersonate/{{ gw.id }}" class="px-3 py-1.5 text-xs bg-brand-highlight/20 text-brand-highlight rounded-lg hover:bg-brand-highlight/30 transition-colors">
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center"> Impersonate
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> </a>
<button onclick="deleteUser('{{ gw.id }}', '{{ gw.display_name or gw.email }}')"
class="text-red-400 hover:text-red-300 p-1.5 rounded-lg hover:bg-red-400/10 transition-colors" title="Nutzer löschen">
<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="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>
<div> </div>
<p class="font-medium text-white">Post erstellen</p> </div>
<p class="text-sm text-gray-400">Content generieren</p> {% endfor %}
</div>
</a> {% if ghostwriters|length == 0 %}
<a href="/admin/posts" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors"> <div class="card-bg rounded-xl border p-8 text-center text-gray-400">
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center"> Keine Ghostwriter vorhanden
<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> </div>
</div> {% endif %}
<div> </div>
<p class="font-medium text-white">Alle Posts</p>
<p class="text-sm text-gray-400">Übersicht anzeigen</p> <!-- Delete Confirmation Modal -->
</div> <div id="deleteModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
</a> <div class="card-bg rounded-xl border p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-2">Nutzer löschen</h3>
<p class="text-gray-300 mb-4">Bist du sicher, dass du <strong id="deleteUserName" class="text-red-400"></strong> und alle zugehörigen Daten löschen möchtest? Dies kann nicht rückgängig gemacht werden.</p>
<div class="flex justify-end gap-3">
<button onclick="closeDeleteModal()" class="px-4 py-2 rounded-lg bg-brand-bg-light text-gray-300 hover:bg-brand-bg transition-colors">Abbrechen</button>
<button onclick="confirmDelete()" class="px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700 transition-colors">Löschen</button>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
let currentTab = 'companies';
let deleteUserId = null;
function showTab(tab) {
document.getElementById('content-companies').classList.toggle('hidden', tab !== 'companies');
document.getElementById('content-ghostwriters').classList.toggle('hidden', tab !== 'ghostwriters');
document.getElementById('tab-companies').className = tab === 'companies'
? 'px-5 py-2 rounded-lg font-medium transition-colors btn-primary'
: 'px-5 py-2 rounded-lg font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg';
document.getElementById('tab-ghostwriters').className = tab === 'ghostwriters'
? 'px-5 py-2 rounded-lg font-medium transition-colors btn-primary'
: 'px-5 py-2 rounded-lg font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg';
currentTab = tab;
filterUsers();
}
function toggleCompany(id) {
const el = document.getElementById(id);
const chevron = document.getElementById('chevron-' + id);
el.classList.toggle('hidden');
chevron.style.transform = el.classList.contains('hidden') ? '' : 'rotate(180deg)';
}
function filterUsers() {
const query = document.getElementById('searchInput').value.toLowerCase().trim();
const container = document.getElementById('content-' + currentTab);
const cards = container.querySelectorAll('.user-card');
cards.forEach(card => {
const search = card.getAttribute('data-search') || '';
card.style.display = !query || search.includes(query) ? '' : 'none';
});
}
function deleteUser(userId, name) {
deleteUserId = userId;
document.getElementById('deleteUserName').textContent = name;
document.getElementById('deleteModal').classList.remove('hidden');
}
function closeDeleteModal() {
document.getElementById('deleteModal').classList.add('hidden');
deleteUserId = null;
}
async function confirmDelete() {
if (!deleteUserId) return;
try {
const resp = await fetch(`/admin/api/users/${deleteUserId}`, { method: 'DELETE' });
if (resp.ok) {
window.location.reload();
} else {
const data = await resp.json();
alert('Fehler: ' + (data.detail || 'Unbekannter Fehler'));
}
} catch (e) {
alert('Fehler: ' + e.message);
}
closeDeleteModal();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,304 @@
{% extends "base.html" %}
{% block title %}Lizenzschlüssel - Admin{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Lizenzschlüssel</h1>
<p class="text-gray-400">Verwalte Lizenzschlüssel für Unternehmensregistrierungen</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 %}
<!-- Summary Cards -->
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="card-bg rounded-xl border p-6">
<p class="text-gray-400 text-sm mb-1">Gesamt</p>
<p class="text-3xl font-bold text-white">{{ total_keys }}</p>
</div>
<div class="card-bg rounded-xl border p-6">
<p class="text-gray-400 text-sm mb-1">Verfügbar</p>
<p class="text-3xl font-bold text-green-400">{{ available_keys }}</p>
</div>
<div class="card-bg rounded-xl border p-6">
<p class="text-gray-400 text-sm mb-1">Verwendet</p>
<p class="text-3xl font-bold text-gray-500">{{ used_keys }}</p>
</div>
</div>
<!-- Generate Button -->
<button onclick="openGenerateModal()"
class="px-6 py-3 rounded-lg font-medium transition-colors btn-primary mb-6 flex items-center gap-2">
<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>
Neuen Schlüssel generieren
</button>
<!-- License Keys Table -->
<div class="card-bg rounded-xl border overflow-hidden">
<table class="w-full">
<thead class="bg-brand-bg-dark border-b border-brand-bg-light">
<tr>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase">Schlüssel</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase">Limits</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase">Status</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase">Erstellt</th>
<th class="px-6 py-4 text-right text-xs font-medium text-gray-400 uppercase">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-brand-bg-light">
{% for key in keys %}
<tr class="hover:bg-brand-bg/30">
<td class="px-6 py-4">
<div class="font-mono text-white font-medium">{{ key.key }}</div>
{% if key.description %}
<div class="text-sm text-gray-400 mt-1">{{ key.description }}</div>
{% endif %}
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-300 space-y-1">
<div>👥 {{ key.max_employees }} Mitarbeiter</div>
<div>📝 {{ key.max_posts_per_day }} Posts/Tag</div>
<div>🔍 {{ key.max_researches_per_day }} Researches/Tag</div>
</div>
</td>
<td class="px-6 py-4">
{% if key.used %}
<span class="px-3 py-1 bg-gray-600/30 text-gray-400 rounded-lg text-sm">
Verwendet
</span>
{% else %}
<span class="px-3 py-1 bg-green-600/30 text-green-400 rounded-lg text-sm">
Verfügbar
</span>
{% endif %}
</td>
<td class="px-6 py-4 text-sm text-gray-400">
{{ key.created_at.strftime('%d.%m.%Y') if key.created_at else '-' }}
</td>
<td class="px-6 py-4 text-right">
<button onclick="editKey('{{ key.id }}', {{ key.max_employees }}, {{ key.max_posts_per_day }}, {{ key.max_researches_per_day }}, '{{ key.description or '' }}')"
class="text-blue-400 hover:text-blue-300 p-2 rounded transition-colors"
title="Bearbeiten">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
{% if not key.used %}
<button onclick="copyKey('{{ key.key }}')"
class="text-brand-highlight hover:text-brand-highlight-dark p-2 rounded transition-colors"
title="Kopieren">
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
<button onclick="deleteKey('{{ key.id }}')"
class="text-red-400 hover:text-red-300 p-2 rounded transition-colors"
title="Löschen">
<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>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Edit Modal -->
<div id="editModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
<h2 class="text-2xl font-bold text-white mb-6">Lizenzschlüssel bearbeiten</h2>
<form id="editForm" class="space-y-4">
<input type="hidden" id="edit_key_id" name="key_id">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Mitarbeiter
</label>
<input type="number" id="edit_max_employees" name="max_employees" min="1" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Posts pro Tag
</label>
<input type="number" id="edit_max_posts_per_day" name="max_posts_per_day" min="1" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Researches pro Tag
</label>
<input type="number" id="edit_max_researches_per_day" name="max_researches_per_day" min="1" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Beschreibung (optional)
</label>
<input type="text" id="edit_description" name="description" placeholder="z.B. Starter Plan, Premium Plan..."
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium">
Speichern
</button>
<button type="button" onclick="closeEditModal()"
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors">
Abbrechen
</button>
</div>
</form>
</div>
</div>
<!-- Generate Modal -->
<div id="generateModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
<h2 class="text-2xl font-bold text-white mb-6">Neuen Schlüssel generieren</h2>
<form id="generateForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Mitarbeiter
</label>
<input type="number" name="max_employees" min="1" value="5" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Posts pro Tag
</label>
<input type="number" name="max_posts_per_day" min="1" value="10" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Max. Researches pro Tag
</label>
<input type="number" name="max_researches_per_day" min="1" value="5" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Beschreibung (optional)
</label>
<input type="text" name="description" placeholder="z.B. Starter Plan, Premium Plan..."
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium">
Generieren
</button>
<button type="button" onclick="closeGenerateModal()"
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors">
Abbrechen
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function openGenerateModal() {
document.getElementById('generateModal').classList.remove('hidden');
}
function closeGenerateModal() {
document.getElementById('generateModal').classList.add('hidden');
document.getElementById('generateForm').reset();
}
function editKey(keyId, maxEmployees, maxPostsPerDay, maxResearchesPerDay, description) {
document.getElementById('edit_key_id').value = keyId;
document.getElementById('edit_max_employees').value = maxEmployees;
document.getElementById('edit_max_posts_per_day').value = maxPostsPerDay;
document.getElementById('edit_max_researches_per_day').value = maxResearchesPerDay;
document.getElementById('edit_description').value = description;
document.getElementById('editModal').classList.remove('hidden');
}
function closeEditModal() {
document.getElementById('editModal').classList.add('hidden');
document.getElementById('editForm').reset();
}
document.getElementById('editForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const keyId = formData.get('key_id');
try {
const response = await fetch(`/admin/api/license-keys/${keyId}`, {
method: 'PATCH',
body: formData
});
if (!response.ok) throw new Error('Update failed');
alert('Lizenzschlüssel aktualisiert!');
location.reload();
} catch (error) {
alert('Fehler beim Aktualisieren: ' + error.message);
}
});
document.getElementById('generateForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
try {
const response = await fetch('/admin/api/license-keys/generate', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Generation failed');
const data = await response.json();
alert(`Schlüssel generiert: ${data.key.key}\n\nBitte kopiere diesen Schlüssel jetzt!`);
location.reload();
} catch (error) {
alert('Fehler beim Generieren: ' + error.message);
}
});
function copyKey(key) {
navigator.clipboard.writeText(key);
alert('Schlüssel kopiert: ' + key);
}
async function deleteKey(keyId) {
if (!confirm('Schlüssel wirklich löschen?')) return;
try {
const response = await fetch(`/admin/api/license-keys/${keyId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Delete failed');
location.reload();
} catch (error) {
alert('Fehler beim Löschen: ' + error.message);
}
}
</script>
{% endblock %}

View File

@@ -1,274 +0,0 @@
{% extends "base.html" %}
{% block title %}Neuer Kunde - LinkedIn Posts{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Neuer Kunde</h1>
<p class="text-gray-400">Richte einen neuen Kunden ein und starte das initiale Setup</p>
</div>
<div class="max-w-2xl">
<form id="customerForm" class="card-bg rounded-xl border p-6 space-y-6">
<!-- Basic Info -->
<div>
<h3 class="text-lg font-semibold text-white mb-4">Basis-Informationen</h3>
<div class="grid gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Name *</label>
<input type="text" name="name" required class="w-full input-bg border rounded-lg px-4 py-2 text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">LinkedIn URL *</label>
<input type="url" name="linkedin_url" required placeholder="https://www.linkedin.com/in/username" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Firma</label>
<input type="text" name="company_name" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">E-Mail</label>
<input type="email" name="email" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
</div>
</div>
</div>
</div>
<!-- Persona -->
<div>
<h3 class="text-lg font-semibold text-white mb-4">Persona & Stil</h3>
<div class="grid gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Persona</label>
<textarea name="persona" rows="3" placeholder="Beschreibe die Expertise, Positionierung und den Charakter der Person..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Ansprache</label>
<input type="text" name="form_of_address" placeholder="z.B. Duzen (Du/Euch) oder Siezen (Sie)" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Style Guide</label>
<textarea name="style_guide" rows="3" placeholder="Beschreibe den Schreibstil, Tonalität und Richtlinien..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
</div>
</div>
</div>
<!-- Post Types -->
<div>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">Post-Typen</h3>
<button type="button" id="addPostTypeBtn" class="text-sm text-brand-highlight hover:underline">+ Post-Typ hinzufügen</button>
</div>
<p class="text-sm text-gray-400 mb-4">Definiere verschiedene Arten von Posts (z.B. "Thought Leader", "Case Study", "How-To"). Diese werden zur Kategorisierung und typ-spezifischen Analyse verwendet.</p>
<div id="postTypesContainer" class="space-y-4">
<!-- Post type entries will be added here -->
</div>
</div>
<!-- Progress Area -->
<div id="progressArea" class="hidden">
<div class="bg-brand-bg rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span id="progressMessage" class="text-gray-300">Starte Setup...</span>
<span id="progressPercent" class="text-gray-400">0%</span>
</div>
<div class="w-full bg-brand-bg-dark rounded-full h-2">
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
</div>
<!-- Result Area -->
<div id="resultArea" class="hidden">
<div id="successResult" class="hidden bg-green-900/30 border border-green-500 rounded-lg p-4">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 text-green-500" 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>
<span class="text-green-300">Setup erfolgreich abgeschlossen!</span>
</div>
</div>
<div id="errorResult" class="hidden bg-red-900/30 border border-red-500 rounded-lg p-4">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 text-red-500" 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>
<span id="errorMessage" class="text-red-300">Fehler beim Setup</span>
</div>
</div>
</div>
<!-- Submit -->
<div class="flex gap-4">
<button type="submit" id="submitBtn" class="flex-1 btn-primary font-medium py-3 rounded-lg transition-colors">
Setup starten
</button>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
const form = document.getElementById('customerForm');
const submitBtn = document.getElementById('submitBtn');
const progressArea = document.getElementById('progressArea');
const resultArea = document.getElementById('resultArea');
const progressBar = document.getElementById('progressBar');
const progressMessage = document.getElementById('progressMessage');
const progressPercent = document.getElementById('progressPercent');
const postTypesContainer = document.getElementById('postTypesContainer');
const addPostTypeBtn = document.getElementById('addPostTypeBtn');
let postTypeIndex = 0;
function createPostTypeEntry() {
const index = postTypeIndex++;
const entry = document.createElement('div');
entry.className = 'bg-brand-bg rounded-lg p-4 border border-brand-bg-light';
entry.id = `postType_${index}`;
entry.innerHTML = `
<div class="flex justify-between items-start mb-3">
<span class="text-sm font-medium text-gray-300">Post-Typ ${index + 1}</span>
<button type="button" onclick="removePostType(${index})" class="text-red-400 hover:text-red-300 text-sm">Entfernen</button>
</div>
<div class="grid gap-3">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-400 mb-1">Name *</label>
<input type="text" data-pt-field="name" data-pt-index="${index}" required placeholder="z.B. Thought Leader" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Beschreibung</label>
<input type="text" data-pt-field="description" data-pt-index="${index}" placeholder="Kurze Beschreibung" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
</div>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Identifizierende Hashtags (kommagetrennt)</label>
<input type="text" data-pt-field="hashtags" data-pt-index="${index}" placeholder="#ThoughtLeader, #Insight, #Leadership" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Keywords (kommagetrennt)</label>
<input type="text" data-pt-field="keywords" data-pt-index="${index}" placeholder="Erfahrung, Learnings, Meinung" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
</div>
<details class="mt-2">
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-300">Erweiterte Eigenschaften</summary>
<div class="mt-3 grid gap-3">
<div>
<label class="block text-xs text-gray-400 mb-1">Zweck</label>
<input type="text" data-pt-field="purpose" data-pt-index="${index}" placeholder="z.B. Expertise zeigen, Meinungsführerschaft etablieren" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Typische Tonalität</label>
<input type="text" data-pt-field="tone" data-pt-index="${index}" placeholder="z.B. reflektiert, provokativ, inspirierend" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Zielgruppe</label>
<input type="text" data-pt-field="target_audience" data-pt-index="${index}" placeholder="z.B. Führungskräfte, Entscheider" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
</div>
</div>
</details>
</div>
`;
postTypesContainer.appendChild(entry);
}
function removePostType(index) {
const entry = document.getElementById(`postType_${index}`);
if (entry) {
entry.remove();
}
}
function collectPostTypes() {
const postTypes = [];
const entries = postTypesContainer.querySelectorAll('[id^="postType_"]');
entries.forEach(entry => {
const index = entry.id.split('_')[1];
const name = entry.querySelector(`[data-pt-field="name"][data-pt-index="${index}"]`)?.value?.trim();
if (name) {
const hashtagsRaw = entry.querySelector(`[data-pt-field="hashtags"][data-pt-index="${index}"]`)?.value || '';
const keywordsRaw = entry.querySelector(`[data-pt-field="keywords"][data-pt-index="${index}"]`)?.value || '';
postTypes.push({
name: name,
description: entry.querySelector(`[data-pt-field="description"][data-pt-index="${index}"]`)?.value?.trim() || null,
identifying_hashtags: hashtagsRaw.split(',').map(h => h.trim()).filter(h => h),
identifying_keywords: keywordsRaw.split(',').map(k => k.trim()).filter(k => k),
semantic_properties: {
purpose: entry.querySelector(`[data-pt-field="purpose"][data-pt-index="${index}"]`)?.value?.trim() || null,
typical_tone: entry.querySelector(`[data-pt-field="tone"][data-pt-index="${index}"]`)?.value?.trim() || null,
target_audience: entry.querySelector(`[data-pt-field="target_audience"][data-pt-index="${index}"]`)?.value?.trim() || null
}
});
}
});
return postTypes;
}
addPostTypeBtn.addEventListener('click', createPostTypeEntry);
form.addEventListener('submit', async (e) => {
e.preventDefault();
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gestartet...';
progressArea.classList.remove('hidden');
resultArea.classList.add('hidden');
const formData = new FormData(form);
// Add post types as JSON
const postTypes = collectPostTypes();
if (postTypes.length > 0) {
formData.append('post_types_json', JSON.stringify(postTypes));
}
try {
const response = await fetch('/admin/api/customers', {
method: 'POST',
body: formData
});
const data = await response.json();
// Poll for progress
const taskId = data.task_id;
const pollInterval = setInterval(async () => {
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
const status = await statusResponse.json();
progressBar.style.width = `${status.progress}%`;
progressPercent.textContent = `${status.progress}%`;
progressMessage.textContent = status.message;
if (status.status === 'completed') {
clearInterval(pollInterval);
progressArea.classList.add('hidden');
resultArea.classList.remove('hidden');
document.getElementById('successResult').classList.remove('hidden');
submitBtn.textContent = 'Setup starten';
submitBtn.disabled = false;
form.reset();
postTypesContainer.innerHTML = '';
postTypeIndex = 0;
} else if (status.status === 'error') {
clearInterval(pollInterval);
progressArea.classList.add('hidden');
resultArea.classList.remove('hidden');
document.getElementById('errorResult').classList.remove('hidden');
document.getElementById('errorMessage').textContent = status.message;
submitBtn.textContent = 'Setup starten';
submitBtn.disabled = false;
}
}, 1000);
} catch (error) {
progressArea.classList.add('hidden');
resultArea.classList.remove('hidden');
document.getElementById('errorResult').classList.remove('hidden');
document.getElementById('errorMessage').textContent = error.message;
submitBtn.textContent = 'Setup starten';
submitBtn.disabled = false;
}
});
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -1,152 +0,0 @@
{% extends "base.html" %}
{% block title %}Alle Posts - LinkedIn Posts{% endblock %}
{% block head %}
<style>
.post-card {
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
border: 1px solid rgba(61, 72, 72, 0.6);
transition: all 0.2s ease;
}
.post-card:hover {
border-color: rgba(255, 199, 0, 0.3);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.customer-header {
background: linear-gradient(90deg, rgba(255, 199, 0, 0.1) 0%, transparent 100%);
}
.score-ring {
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.75rem;
}
.score-high { background: rgba(34, 197, 94, 0.2); border: 2px solid rgba(34, 197, 94, 0.5); color: #86efac; }
.score-medium { background: rgba(234, 179, 8, 0.2); border: 2px solid rgba(234, 179, 8, 0.5); color: #fde047; }
.score-low { background: rgba(239, 68, 68, 0.2); border: 2px solid rgba(239, 68, 68, 0.5); color: #fca5a5; }
</style>
{% endblock %}
{% block content %}
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Alle Posts</h1>
<p class="text-gray-400">{{ total_posts }} generierte Posts</p>
</div>
<a href="/admin/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
<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 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
Neuer Post
</a>
</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 %}
{% if customers_with_posts %}
<div class="space-y-8">
{% for item in customers_with_posts %}
{% if item.posts %}
<div class="card-bg rounded-xl border overflow-hidden">
<!-- Customer Header -->
<div class="customer-header px-6 py-4 border-b border-brand-bg-light">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl flex items-center justify-center shadow-lg overflow-hidden {{ 'bg-brand-highlight' if not item.profile_picture else '' }}">
{% if item.profile_picture %}
<img src="{{ item.profile_picture }}" alt="{{ item.customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
{% else %}
<span class="text-brand-bg-dark font-bold text-lg">{{ item.customer.name[0] | upper }}</span>
{% endif %}
</div>
<div>
<h3 class="font-semibold text-white text-lg">{{ item.customer.name }}</h3>
<p class="text-sm text-gray-400">{{ item.customer.company_name or 'Kein Unternehmen' }}</p>
</div>
</div>
<div class="flex items-center gap-4">
<span class="px-4 py-1.5 bg-brand-bg rounded-full text-sm text-gray-300 font-medium">
{{ item.post_count }} Post{{ 's' if item.post_count != 1 else '' }}
</span>
</div>
</div>
</div>
<!-- Posts Grid -->
<div class="p-4">
<div class="grid gap-3">
{% for post in item.posts %}
<a href="/admin/posts/{{ post.id }}" class="post-card rounded-xl p-4 block group">
<div class="flex items-center gap-4">
<!-- Score Circle -->
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
{% set score = post.critic_feedback[-1].overall_score %}
<div class="score-ring flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
{{ score }}
</div>
{% else %}
<div class="score-ring flex-shrink-0 bg-brand-bg-dark border-2 border-brand-bg-light text-gray-500">
</div>
{% endif %}
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-3">
<h4 class="font-medium text-white group-hover:text-brand-highlight transition-colors truncate">
{{ post.topic_title or 'Untitled' }}
</h4>
<span class="flex-shrink-0 px-2 py-0.5 text-xs rounded font-medium {{ 'bg-green-600/20 text-green-400 border border-green-600/30' if post.status == 'approved' else 'bg-yellow-600/20 text-yellow-400 border border-yellow-600/30' }}">
{{ post.status | capitalize }}
</span>
</div>
<div class="flex items-center gap-4 mt-1.5 text-sm text-gray-500">
<span class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}
</span>
<span class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" 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>
{{ post.created_at.strftime('%H:%M') if post.created_at else '' }}
</span>
<span class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.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>
{{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }}
</span>
</div>
</div>
<!-- Arrow -->
<svg class="w-5 h-5 text-gray-600 group-hover:text-brand-highlight transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</div>
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% else %}
<div class="card-bg rounded-xl border p-12 text-center">
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</div>
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Posts</h3>
<p class="text-gray-400 mb-6 max-w-md mx-auto">Erstelle deinen ersten LinkedIn Post mit KI-Unterstützung.</p>
<a href="/admin/create" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg 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 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
Post erstellen
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,571 +0,0 @@
{% extends "base.html" %}
{% block title %}Gescrapte Posts - LinkedIn Posts{% endblock %}
{% block head %}
<style>
.post-card {
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
border: 1px solid rgba(61, 72, 72, 0.6);
transition: all 0.2s ease;
}
.post-card:hover {
border-color: rgba(255, 199, 0, 0.3);
}
.post-card.selected {
border-color: rgba(255, 199, 0, 0.6);
background: linear-gradient(135deg, rgba(255, 199, 0, 0.05) 0%, rgba(45, 56, 56, 0.4) 100%);
}
.type-badge {
transition: all 0.15s ease;
}
.type-badge:hover {
transform: scale(1.02);
}
.type-badge.active {
background-color: rgba(255, 199, 0, 0.2);
border-color: #ffc700;
}
.post-content-preview {
max-height: 150px;
overflow: hidden;
position: relative;
}
.post-content-preview::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
background: linear-gradient(transparent, rgba(45, 56, 56, 0.9));
}
.post-content-expanded {
max-height: none;
}
.post-content-expanded::after {
display: none;
}
</style>
{% endblock %}
{% block content %}
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Gescrapte Posts verwalten</h1>
<p class="text-gray-400">Posts manuell kategorisieren und Post-Typ-Analyse triggern</p>
</div>
</div>
</div>
<!-- Customer Selection -->
<div class="card-bg rounded-xl border p-6 mb-6">
<div class="flex flex-wrap items-end gap-4">
<div class="flex-1 min-w-64">
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
<select id="customerSelect" class="w-full input-bg border rounded-lg px-4 py-3 text-white">
<option value="">-- Kunde wählen --</option>
{% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
{% endfor %}
</select>
</div>
<div class="flex gap-2">
<button id="classifyAllBtn" class="hidden px-4 py-3 bg-brand-bg hover:bg-brand-bg-light border border-brand-bg-light rounded-lg text-white transition-colors">
<span class="flex items-center gap-2">
<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>
Auto-Klassifizieren
</span>
</button>
<button id="analyzeTypesBtn" class="hidden px-4 py-3 btn-primary rounded-lg font-medium transition-colors">
<span class="flex items-center gap-2">
<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>
Post-Typen analysieren
</span>
</button>
</div>
</div>
</div>
<!-- Progress Area -->
<div id="progressArea" class="hidden card-bg rounded-xl border p-6 mb-6">
<div class="flex items-center justify-between mb-2">
<span id="progressMessage" class="text-gray-300">Arbeite...</span>
<span id="progressPercent" class="text-gray-400">0%</span>
</div>
<div class="w-full bg-brand-bg-dark rounded-full h-2">
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<!-- Stats & Post Types -->
<div id="statsArea" class="hidden mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div class="card-bg rounded-xl border p-4">
<div class="text-2xl font-bold text-white" id="totalPostsCount">0</div>
<div class="text-sm text-gray-400">Gesamt Posts</div>
</div>
<div class="card-bg rounded-xl border p-4">
<div class="text-2xl font-bold text-green-400" id="classifiedCount">0</div>
<div class="text-sm text-gray-400">Klassifiziert</div>
</div>
<div class="card-bg rounded-xl border p-4">
<div class="text-2xl font-bold text-yellow-400" id="unclassifiedCount">0</div>
<div class="text-sm text-gray-400">Nicht klassifiziert</div>
</div>
<div class="card-bg rounded-xl border p-4">
<div class="text-2xl font-bold text-brand-highlight" id="postTypesCount">0</div>
<div class="text-sm text-gray-400">Post-Typen</div>
</div>
</div>
<!-- Post Type Filter -->
<div class="flex flex-wrap gap-2 mb-4">
<button onclick="filterByType(null)" id="filter_all" class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
Alle
</button>
<button onclick="filterByType('unclassified')" id="filter_unclassified" class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
Nicht klassifiziert
</button>
<div id="postTypeFilters" class="contents">
<!-- Post type filter buttons will be added here -->
</div>
</div>
</div>
<!-- Posts List -->
<div id="postsArea" class="hidden">
<div id="postsList" class="space-y-4">
<p class="text-gray-400">Lade Posts...</p>
</div>
</div>
<!-- Empty State -->
<div id="emptyState" class="hidden card-bg rounded-xl border p-12 text-center">
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-gray-600" 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>
</div>
<h3 class="text-xl font-semibold text-white mb-2">Keine gescrapten Posts</h3>
<p class="text-gray-400 mb-6 max-w-md mx-auto">Für diesen Kunden wurden noch keine LinkedIn Posts gescrapet.</p>
</div>
{% if not customers %}
<div class="bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/admin/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
</div>
{% endif %}
<!-- Post Detail Modal -->
<div id="postModal" class="fixed inset-0 bg-black/70 hidden items-center justify-center z-50 p-4">
<div class="bg-brand-bg-dark rounded-xl border border-brand-bg-light max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col shadow-2xl">
<div class="p-4 border-b border-brand-bg-light flex items-center justify-between bg-brand-bg">
<h3 class="text-lg font-semibold text-white">Post Details</h3>
<button onclick="closeModal()" class="text-gray-400 hover:text-white p-1 hover:bg-brand-bg-light rounded">
<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="p-6 overflow-y-auto flex-1">
<div id="modalContent"></div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const customerSelect = document.getElementById('customerSelect');
const classifyAllBtn = document.getElementById('classifyAllBtn');
const analyzeTypesBtn = document.getElementById('analyzeTypesBtn');
const progressArea = document.getElementById('progressArea');
const progressBar = document.getElementById('progressBar');
const progressMessage = document.getElementById('progressMessage');
const progressPercent = document.getElementById('progressPercent');
const statsArea = document.getElementById('statsArea');
const postsArea = document.getElementById('postsArea');
const postsList = document.getElementById('postsList');
const emptyState = document.getElementById('emptyState');
const postTypeFilters = document.getElementById('postTypeFilters');
const postModal = document.getElementById('postModal');
const modalContent = document.getElementById('modalContent');
let currentPosts = [];
let currentPostTypes = [];
let currentFilter = null;
customerSelect.addEventListener('change', async () => {
const customerId = customerSelect.value;
if (!customerId) {
statsArea.classList.add('hidden');
postsArea.classList.add('hidden');
emptyState.classList.add('hidden');
classifyAllBtn.classList.add('hidden');
analyzeTypesBtn.classList.add('hidden');
return;
}
await loadCustomerData(customerId);
});
async function loadCustomerData(customerId) {
// Load post types
try {
const ptResponse = await fetch(`/admin/api/customers/${customerId}/post-types`);
const ptData = await ptResponse.json();
currentPostTypes = ptData.post_types || [];
// Update post type filters
postTypeFilters.innerHTML = currentPostTypes.map(pt => `
<button onclick="filterByType('${pt.id}')" id="filter_${pt.id}"
class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
${escapeHtml(pt.name)}
<span class="ml-1 text-xs text-gray-400">(${pt.analyzed_post_count || 0})</span>
${pt.has_analysis ? '<span class="ml-1 text-green-400">*</span>' : ''}
</button>
`).join('');
document.getElementById('postTypesCount').textContent = currentPostTypes.length;
} catch (error) {
console.error('Failed to load post types:', error);
}
// Load posts
try {
const response = await fetch(`/admin/api/customers/${customerId}/linkedin-posts`);
const data = await response.json();
console.log('API Response:', data);
if (data.error) {
console.error('API Error:', data.error);
postsList.innerHTML = `<p class="text-red-400">API Fehler: ${escapeHtml(data.error)}</p>`;
postsArea.classList.remove('hidden');
return;
}
currentPosts = data.posts || [];
console.log(`Loaded ${currentPosts.length} posts`);
if (currentPosts.length === 0) {
statsArea.classList.add('hidden');
postsArea.classList.add('hidden');
emptyState.classList.remove('hidden');
classifyAllBtn.classList.add('hidden');
analyzeTypesBtn.classList.add('hidden');
return;
}
// Update stats
const classified = currentPosts.filter(p => p.post_type_id).length;
const unclassified = currentPosts.length - classified;
document.getElementById('totalPostsCount').textContent = currentPosts.length;
document.getElementById('classifiedCount').textContent = classified;
document.getElementById('unclassifiedCount').textContent = unclassified;
statsArea.classList.remove('hidden');
postsArea.classList.remove('hidden');
emptyState.classList.add('hidden');
classifyAllBtn.classList.remove('hidden');
analyzeTypesBtn.classList.remove('hidden');
currentFilter = null;
filterByType(null);
} catch (error) {
console.error('Failed to load posts:', error);
postsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
}
}
function filterByType(typeId) {
currentFilter = typeId;
// Update filter button styles
document.querySelectorAll('.type-badge').forEach(btn => {
const btnId = btn.id.replace('filter_', '');
const isActive = (typeId === null && btnId === 'all') ||
(typeId === 'unclassified' && btnId === 'unclassified') ||
(btnId === typeId);
if (isActive) {
btn.classList.add('active', 'bg-brand-highlight/20', 'border-brand-highlight');
btn.classList.remove('bg-brand-bg', 'border-brand-bg-light');
} else {
btn.classList.remove('active', 'bg-brand-highlight/20', 'border-brand-highlight');
btn.classList.add('bg-brand-bg', 'border-brand-bg-light');
}
});
// Filter posts
let filteredPosts = currentPosts;
if (typeId === 'unclassified') {
filteredPosts = currentPosts.filter(p => !p.post_type_id);
} else if (typeId) {
filteredPosts = currentPosts.filter(p => p.post_type_id === typeId);
}
renderPosts(filteredPosts);
}
function renderPosts(posts) {
if (posts.length === 0) {
postsList.innerHTML = '<p class="text-gray-400 text-center py-8">Keine Posts in dieser Kategorie.</p>';
return;
}
postsList.innerHTML = posts.map((post, index) => {
const postType = currentPostTypes.find(pt => pt.id === post.post_type_id);
const postText = post.post_text || '';
const previewText = postText.substring(0, 300);
return `
<div class="post-card rounded-xl p-4 cursor-pointer" data-post-id="${post.id}" onclick="openPostModal('${post.id}')">
<div class="flex items-start gap-4">
<div class="flex-1 min-w-0">
<!-- Header -->
<div class="flex items-center gap-2 mb-3 flex-wrap">
${postType ? `
<span class="px-2 py-1 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">
${escapeHtml(postType.name)}
</span>
<span class="text-xs text-gray-500">${post.classification_method || 'unknown'} (${Math.round((post.classification_confidence || 0) * 100)}%)</span>
` : `
<span class="px-2 py-1 text-xs font-medium bg-gray-600/30 text-gray-400 rounded">
Nicht klassifiziert
</span>
`}
${post.posted_at ? `
<span class="text-xs text-gray-500">${new Date(post.posted_at).toLocaleDateString('de-DE')}</span>
` : ''}
${post.engagement_score ? `
<span class="text-xs text-gray-500">Engagement: ${post.engagement_score}</span>
` : ''}
</div>
<!-- Content Preview -->
<div class="post-content-preview text-gray-300 text-sm whitespace-pre-wrap mb-3">
${escapeHtml(previewText)}${postText.length > 300 ? '...' : ''}
</div>
<!-- Click hint & Type badges -->
<div class="flex items-center justify-between flex-wrap gap-2" onclick="event.stopPropagation()">
<span class="text-xs text-gray-500 italic">Klicken für Vollansicht</span>
<div class="flex items-center gap-2 flex-wrap">
${currentPostTypes.map(pt => `
<button onclick="event.stopPropagation(); classifyPost('${post.id}', '${pt.id}')"
class="px-2 py-1 text-xs rounded transition-colors ${post.post_type_id === pt.id ? 'bg-brand-highlight/30 text-brand-highlight border border-brand-highlight' : 'bg-brand-bg hover:bg-brand-bg-light text-gray-300 border border-brand-bg-light'}">
${escapeHtml(pt.name)}
</button>
`).join('')}
${post.post_type_id ? `
<button onclick="event.stopPropagation(); classifyPost('${post.id}', null)" class="px-2 py-1 text-xs rounded bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-900/50">
</button>
` : ''}
</div>
</div>
</div>
</div>
</div>
`;
}).join('');
}
async function classifyPost(postId, postTypeId) {
const customerId = customerSelect.value;
if (!customerId) return;
try {
const response = await fetch(`/admin/api/linkedin-posts/${postId}/classify`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ post_type_id: postTypeId })
});
if (!response.ok) {
throw new Error('Failed to classify post');
}
// Update local data
const post = currentPosts.find(p => p.id === postId);
if (post) {
post.post_type_id = postTypeId;
post.classification_method = 'manual';
post.classification_confidence = 1.0;
}
// Update stats
const classified = currentPosts.filter(p => p.post_type_id).length;
document.getElementById('classifiedCount').textContent = classified;
document.getElementById('unclassifiedCount').textContent = currentPosts.length - classified;
// Re-render
filterByType(currentFilter);
} catch (error) {
console.error('Failed to classify post:', error);
alert('Fehler beim Klassifizieren: ' + error.message);
}
}
function openPostModal(postId) {
const post = currentPosts.find(p => p.id === postId);
if (!post) return;
const postType = currentPostTypes.find(pt => pt.id === post.post_type_id);
modalContent.innerHTML = `
<div class="mb-4 flex items-center gap-3 flex-wrap">
${postType ? `
<span class="px-3 py-1.5 text-sm font-medium bg-brand-highlight/20 text-brand-highlight rounded-lg">
${escapeHtml(postType.name)}
</span>
` : `
<span class="px-3 py-1.5 text-sm font-medium bg-gray-600/30 text-gray-400 rounded-lg">
Nicht klassifiziert
</span>
`}
${post.posted_at ? `
<span class="text-sm text-gray-500">${new Date(post.posted_at).toLocaleDateString('de-DE')}</span>
` : ''}
${post.engagement_score ? `
<span class="text-sm text-gray-500">Engagement: ${post.engagement_score}</span>
` : ''}
</div>
<div class="bg-brand-bg rounded-xl p-6 mb-6 border border-brand-bg-light max-h-[50vh] overflow-y-auto">
<div class="whitespace-pre-wrap text-gray-200 font-sans text-base leading-relaxed">${escapeHtml(post.post_text || '')}</div>
</div>
<div class="flex items-center gap-3 flex-wrap border-t border-brand-bg-light pt-4">
<span class="text-sm text-gray-400 font-medium">Typ zuweisen:</span>
${currentPostTypes.map(pt => `
<button onclick="classifyPost('${post.id}', '${pt.id}'); closeModal();"
class="px-4 py-2 text-sm rounded-lg transition-colors ${post.post_type_id === pt.id ? 'bg-brand-highlight text-brand-bg-dark font-medium' : 'bg-brand-bg hover:bg-brand-bg-light text-gray-300 border border-brand-bg-light'}">
${escapeHtml(pt.name)}
</button>
`).join('')}
${post.post_type_id ? `
<button onclick="classifyPost('${post.id}', null); closeModal();" class="px-4 py-2 text-sm rounded-lg bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-900/50">
Klassifizierung entfernen
</button>
` : ''}
</div>
`;
postModal.classList.remove('hidden');
postModal.classList.add('flex');
}
function closeModal() {
postModal.classList.add('hidden');
postModal.classList.remove('flex');
}
// Close modal on backdrop click
postModal.addEventListener('click', (e) => {
if (e.target === postModal) {
closeModal();
}
});
// Close modal on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !postModal.classList.contains('hidden')) {
closeModal();
}
});
// Auto-classify button
classifyAllBtn.addEventListener('click', async () => {
const customerId = customerSelect.value;
if (!customerId) return;
classifyAllBtn.disabled = true;
progressArea.classList.remove('hidden');
try {
const response = await fetch(`/admin/api/customers/${customerId}/classify-posts`, {
method: 'POST'
});
const data = await response.json();
const taskId = data.task_id;
await pollTask(taskId, async () => {
await loadCustomerData(customerId);
});
} catch (error) {
console.error('Classification failed:', error);
alert('Fehler bei der Klassifizierung: ' + error.message);
} finally {
classifyAllBtn.disabled = false;
progressArea.classList.add('hidden');
}
});
// Analyze post types button
analyzeTypesBtn.addEventListener('click', async () => {
const customerId = customerSelect.value;
if (!customerId) return;
analyzeTypesBtn.disabled = true;
progressArea.classList.remove('hidden');
try {
const response = await fetch(`/admin/api/customers/${customerId}/analyze-post-types`, {
method: 'POST'
});
const data = await response.json();
const taskId = data.task_id;
await pollTask(taskId, async () => {
await loadCustomerData(customerId);
alert('Post-Typ-Analyse abgeschlossen! Die Analysen wurden aktualisiert.');
});
} catch (error) {
console.error('Analysis failed:', error);
alert('Fehler bei der Analyse: ' + error.message);
} finally {
analyzeTypesBtn.disabled = false;
progressArea.classList.add('hidden');
}
});
async function pollTask(taskId, onComplete) {
return new Promise((resolve) => {
const interval = setInterval(async () => {
try {
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
const status = await statusResponse.json();
progressBar.style.width = `${status.progress}%`;
progressPercent.textContent = `${status.progress}%`;
progressMessage.textContent = status.message;
if (status.status === 'completed') {
clearInterval(interval);
await onComplete();
resolve();
} else if (status.status === 'error') {
clearInterval(interval);
alert('Fehler: ' + status.message);
resolve();
}
} catch (error) {
clearInterval(interval);
console.error('Polling error:', error);
resolve();
}
}, 1000);
});
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
{% endblock %}

View File

@@ -0,0 +1,436 @@
{% extends "base.html" %}
{% block title %}Statistiken - Admin{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Statistiken</h1>
<p class="text-gray-400">API-Nutzung und Kosten</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 %}
<!-- Filters -->
<div class="flex flex-wrap items-center gap-4 mb-6">
<!-- Period Selector -->
<div class="flex gap-2">
<button onclick="loadStats('today')" id="period-today"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg">
Heute
</button>
<button onclick="loadStats('week')" id="period-week"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg">
7 Tage
</button>
<button onclick="loadStats('month')" id="period-month"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors btn-primary">
30 Tage
</button>
<button onclick="loadStats('all')" id="period-all"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg">
Gesamt
</button>
</div>
<!-- User/Company Filter -->
<div class="flex items-center gap-2">
<label for="entityFilter" class="text-sm text-gray-400">Filter:</label>
<select id="entityFilter" onchange="onEntityFilterChange()"
class="input-bg border rounded-lg px-3 py-2 text-sm text-white min-w-[250px]">
<option value="">Alle</option>
{% if companies %}
<optgroup label="Firmen">
{% for cd in companies %}
<option value="company:{{ cd.company.id }}">{{ cd.company.name }}</option>
{% endfor %}
</optgroup>
{% endif %}
{% if ghostwriters %}
<optgroup label="Ghostwriter">
{% for gw in ghostwriters %}
<option value="user:{{ gw.id }}">{{ gw.display_name or gw.email }}</option>
{% endfor %}
</optgroup>
{% endif %}
{% if companies %}
<optgroup label="Firmen-Mitglieder">
{% for cd in companies %}
{% for emp in cd.employees %}
<option value="user:{{ emp.id }}">{{ emp.display_name or emp.email }} ({{ cd.company.name }})</option>
{% endfor %}
{% endfor %}
</optgroup>
{% endif %}
</select>
</div>
</div>
<!-- Overview Cards Row 1 -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-4">
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center mx-auto mb-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="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>
</div>
<p class="text-gray-400 text-xs">Tokens</p>
<p class="text-xl font-bold text-white" id="stat-tokens">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-green-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<p class="text-gray-400 text-xs">Kosten (USD)</p>
<p class="text-xl font-bold text-white" id="stat-cost">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-blue-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<p class="text-gray-400 text-xs">API Calls</p>
<p class="text-xl font-bold text-white" id="stat-calls">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</div>
<p class="text-gray-400 text-xs">Erstellt</p>
<p class="text-xl font-bold text-white" id="stat-created">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-amber-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-amber-400" 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>
</div>
<p class="text-gray-400 text-xs">Freigegeben</p>
<p class="text-xl font-bold text-white" id="stat-approved">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-emerald-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</div>
<p class="text-gray-400 text-xs">Veröffentlicht</p>
<p class="text-xl font-bold text-white" id="stat-published">-</p>
</div>
</div>
<!-- Overview Cards Row 2 -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-orange-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
</div>
<p class="text-gray-400 text-xs">Kosten/Post</p>
<p class="text-xl font-bold text-white" id="stat-avg-cost">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-rose-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
</div>
<p class="text-gray-400 text-xs">Monatsprognose</p>
<p class="text-xl font-bold text-white" id="stat-projection">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-cyan-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-cyan-400" 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>
</div>
<p class="text-gray-400 text-xs">Tokens/Post</p>
<p class="text-xl font-bold text-white" id="stat-avg-tokens">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-amber-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-amber-400" 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>
</div>
<p class="text-gray-400 text-xs">Bis Freigabe</p>
<p class="text-xl font-bold text-white" id="stat-time-approval">-</p>
</div>
<div class="card-bg rounded-xl border p-5 text-center">
<div class="w-10 h-10 bg-emerald-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="w-5 h-5 text-emerald-400" 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>
</div>
<p class="text-gray-400 text-xs">Bis Veröffentl.</p>
<p class="text-xl font-bold text-white" id="stat-time-publish">-</p>
</div>
</div>
<!-- Charts Row 1 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Daily Token Usage -->
<div class="card-bg rounded-xl border p-6">
<h3 class="text-lg font-semibold text-white mb-4">Token-Verbrauch pro Tag</h3>
<div id="chart-daily-tokens"></div>
</div>
<!-- Daily Cost -->
<div class="card-bg rounded-xl border p-6">
<h3 class="text-lg font-semibold text-white mb-4">Kosten pro Tag (USD)</h3>
<div id="chart-daily-cost"></div>
</div>
</div>
<!-- Charts Row 2 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- By Model (Pie) -->
<div class="card-bg rounded-xl border p-6">
<h3 class="text-lg font-semibold text-white mb-4">Nutzung nach Modell</h3>
<div id="chart-by-model"></div>
</div>
<!-- By Operation (Bar) -->
<div class="card-bg rounded-xl border p-6">
<h3 class="text-lg font-semibold text-white mb-4">Nutzung nach Operation</h3>
<div id="chart-by-operation"></div>
</div>
</div>
<!-- Charts Row 3: Posts -->
<div class="grid grid-cols-1 gap-6 mb-6">
<div class="card-bg rounded-xl border p-6">
<h3 class="text-lg font-semibold text-white mb-4">Posts pro Tag</h3>
<div id="chart-posts-daily"></div>
</div>
</div>
<!-- Post Pipeline Funnel -->
<div class="card-bg rounded-xl border p-6 mb-6">
<h3 class="text-lg font-semibold text-white mb-4">Post-Pipeline</h3>
<div class="flex items-center justify-center gap-2">
<div class="text-center flex-1">
<div class="bg-purple-600/20 rounded-lg py-4 px-2">
<p class="text-2xl font-bold text-purple-400" id="funnel-created">-</p>
<p class="text-xs text-gray-400 mt-1">Erstellt</p>
</div>
</div>
<div class="text-center flex-shrink-0">
<p class="text-sm font-semibold text-amber-400" id="funnel-approval-rate">-</p>
<svg class="w-6 h-6 text-gray-500 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</div>
<div class="text-center flex-1">
<div class="bg-amber-600/20 rounded-lg py-4 px-2">
<p class="text-2xl font-bold text-amber-400" id="funnel-approved">-</p>
<p class="text-xs text-gray-400 mt-1">Freigegeben</p>
</div>
</div>
<div class="text-center flex-shrink-0">
<p class="text-sm font-semibold text-emerald-400" id="funnel-publish-rate">-</p>
<svg class="w-6 h-6 text-gray-500 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</div>
<div class="text-center flex-1">
<div class="bg-emerald-600/20 rounded-lg py-4 px-2">
<p class="text-2xl font-bold text-emerald-400" id="funnel-published">-</p>
<p class="text-xs text-gray-400 mt-1">Veröffentlicht</p>
</div>
</div>
</div>
</div>
<!-- No Data Message -->
<div id="no-data-msg" class="hidden card-bg rounded-xl border p-8 text-center text-gray-400 mb-6">
Noch keine API-Nutzungsdaten vorhanden. Statistiken werden automatisch erfasst, sobald Posts erstellt oder Research durchgeführt wird.
</div>
{% endblock %}
{% block scripts %}
<script>
let currentPeriod = 'month';
let filterType = ''; // '' | 'user' | 'company'
let filterId = ''; // UUID
let charts = {};
const chartTheme = {
chart: { background: 'transparent', foreColor: '#9CA3AF' },
grid: { borderColor: '#4a5858' },
tooltip: { theme: 'dark' },
colors: ['#ffc700', '#60A5FA', '#34D399', '#A78BFA', '#F87171']
};
function formatTokens(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return n.toString();
}
function formatHours(h) {
if (h === null || h === undefined || h === 0) return '0 Std';
if (h < 1) return Math.round(h * 60) + ' Min';
if (h >= 24) return (h / 24).toFixed(1) + ' Tage';
return h.toFixed(1) + ' Std';
}
function destroyCharts() {
Object.values(charts).forEach(c => { try { c.destroy(); } catch(e) {} });
charts = {};
}
function renderCharts(data) {
destroyCharts();
const hasData = data.total_calls > 0 || data.total_created > 0;
document.getElementById('no-data-msg').classList.toggle('hidden', hasData);
// Update summary cards
document.getElementById('stat-tokens').textContent = formatTokens(data.total_tokens);
document.getElementById('stat-cost').textContent = '$' + data.total_cost.toFixed(4);
document.getElementById('stat-calls').textContent = data.total_calls.toString();
document.getElementById('stat-created').textContent = (data.total_created || 0).toString();
document.getElementById('stat-approved').textContent = (data.total_approved || 0).toString();
document.getElementById('stat-published').textContent = (data.total_published || 0).toString();
// Derived metrics cards
document.getElementById('stat-avg-cost').textContent = '$' + (data.avg_cost_per_post || 0).toFixed(4);
document.getElementById('stat-projection').textContent = '$' + (data.monthly_projection || 0).toFixed(2);
document.getElementById('stat-avg-tokens').textContent = formatTokens(data.avg_tokens_per_post || 0);
document.getElementById('stat-time-approval').textContent = formatHours(data.avg_hours_to_approval);
document.getElementById('stat-time-publish').textContent = formatHours(data.avg_hours_to_publish);
// Funnel
document.getElementById('funnel-created').textContent = (data.total_created || 0).toString();
document.getElementById('funnel-approved').textContent = (data.total_approved || 0).toString();
document.getElementById('funnel-published').textContent = (data.total_published || 0).toString();
document.getElementById('funnel-approval-rate').textContent = (data.approval_rate || 0) + '%';
document.getElementById('funnel-publish-rate').textContent = (data.publish_rate || 0) + '%';
// Daily tokens line chart (total + per model)
if (data.daily.length > 0) {
const tokenSeries = [{ name: 'Gesamt', data: data.daily.map(d => ({ x: d.date, y: d.tokens })) }];
const costSeries = [{ name: 'Gesamt', data: data.daily.map(d => ({ x: d.date, y: parseFloat(d.cost.toFixed(4)) })) }];
const modelColors = { 'gpt-4o': '#60A5FA', 'gpt-4o-mini': '#A78BFA', 'sonar': '#F87171' };
if (data.daily_by_model) {
for (const [model, days] of Object.entries(data.daily_by_model)) {
tokenSeries.push({ name: model, data: days.map(d => ({ x: d.date, y: d.tokens })) });
costSeries.push({ name: model, data: days.map(d => ({ x: d.date, y: parseFloat(d.cost.toFixed(4)) })) });
}
}
const seriesColors = ['#ffc700'].concat(tokenSeries.slice(1).map(s => modelColors[s.name] || '#9CA3AF'));
const strokeWidths = [2].concat(tokenSeries.slice(1).map(() => 2));
// Gesamt = filled gradient, model lines = no fill
const fillOpacity = [0.3].concat(tokenSeries.slice(1).map(() => 0));
charts.dailyTokens = new ApexCharts(document.getElementById('chart-daily-tokens'), {
...chartTheme,
chart: { ...chartTheme.chart, type: 'area', height: 300 },
series: tokenSeries,
xaxis: { type: 'category', labels: { rotate: -45 } },
yaxis: { labels: { formatter: formatTokens } },
stroke: { curve: 'smooth', width: strokeWidths },
fill: { opacity: fillOpacity },
colors: seriesColors,
legend: { position: 'top', labels: { colors: '#9CA3AF' } },
dataLabels: { enabled: false }
});
charts.dailyTokens.render();
const costColors = ['#34D399'].concat(costSeries.slice(1).map(s => modelColors[s.name] || '#9CA3AF'));
charts.dailyCost = new ApexCharts(document.getElementById('chart-daily-cost'), {
...chartTheme,
chart: { ...chartTheme.chart, type: 'area', height: 300 },
series: costSeries,
xaxis: { type: 'category', labels: { rotate: -45 } },
yaxis: { labels: { formatter: v => '$' + v.toFixed(4) } },
stroke: { curve: 'smooth', width: strokeWidths },
fill: { opacity: fillOpacity },
colors: costColors,
legend: { position: 'top', labels: { colors: '#9CA3AF' } },
dataLabels: { enabled: false }
});
charts.dailyCost.render();
}
// By model pie chart
if (data.by_model.length > 0) {
charts.byModel = new ApexCharts(document.getElementById('chart-by-model'), {
...chartTheme,
chart: { ...chartTheme.chart, type: 'donut', height: 280 },
series: data.by_model.map(m => m.tokens),
labels: data.by_model.map(m => m.model),
legend: { position: 'bottom', labels: { colors: '#9CA3AF' } },
plotOptions: { pie: { donut: { size: '60%' } } }
});
charts.byModel.render();
}
// By operation bar chart
if (data.by_operation.length > 0) {
charts.byOperation = new ApexCharts(document.getElementById('chart-by-operation'), {
...chartTheme,
chart: { ...chartTheme.chart, type: 'bar', height: 280 },
series: [{ name: 'Tokens', data: data.by_operation.map(o => o.tokens) }],
xaxis: { categories: data.by_operation.map(o => o.operation) },
yaxis: { labels: { formatter: formatTokens } },
plotOptions: { bar: { borderRadius: 4, horizontal: false } },
dataLabels: { enabled: false }
});
charts.byOperation.render();
}
// Posts daily chart (created / approved / published)
if (data.posts_daily && data.posts_daily.length > 0) {
charts.postsByDay = new ApexCharts(document.getElementById('chart-posts-daily'), {
...chartTheme,
chart: { ...chartTheme.chart, type: 'bar', height: 300 },
series: [
{ name: 'Erstellt', data: data.posts_daily.map(d => ({ x: d.date, y: d.created })) },
{ name: 'Freigegeben', data: data.posts_daily.map(d => ({ x: d.date, y: d.approved })) },
{ name: 'Veröffentlicht', data: data.posts_daily.map(d => ({ x: d.date, y: d.published })) }
],
xaxis: { type: 'category', labels: { rotate: -45 } },
yaxis: { labels: { formatter: v => Math.round(v).toString() }, forceNiceScale: true },
plotOptions: { bar: { borderRadius: 3, columnWidth: '60%' } },
colors: ['#A78BFA', '#FBBF24', '#34D399'],
legend: { position: 'top', labels: { colors: '#9CA3AF' } },
dataLabels: { enabled: false }
});
charts.postsByDay.render();
}
}
function onEntityFilterChange() {
const val = document.getElementById('entityFilter').value;
if (!val) {
filterType = '';
filterId = '';
} else {
const parts = val.split(':');
filterType = parts[0]; // 'user' or 'company'
filterId = parts[1];
}
loadStats(currentPeriod);
}
async function loadStats(period) {
currentPeriod = period;
// Update period button styles
['today', 'week', 'month', 'all'].forEach(p => {
const btn = document.getElementById('period-' + p);
btn.className = p === period
? 'px-4 py-2 rounded-lg text-sm font-medium transition-colors btn-primary'
: 'px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg';
});
try {
let url = `/admin/api/statistics?period=${period}`;
if (filterType === 'user' && filterId) {
url += `&user_id=${filterId}`;
} else if (filterType === 'company' && filterId) {
url += `&company_id=${filterId}`;
}
const resp = await fetch(url);
const data = await resp.json();
renderCharts(data);
} catch (e) {
console.error('Failed to load stats:', e);
}
}
// Load initial data
loadStats('month');
</script>
{% endblock %}

View File

@@ -1,159 +0,0 @@
{% extends "base.html" %}
{% block title %}Status - LinkedIn Posts{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Status</h1>
<p class="text-gray-400">Übersicht über alle Kunden und deren Setup-Status</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 %}
{% if customer_statuses %}
<div class="grid gap-6">
{% for item in customer_statuses %}
<div class="card-bg rounded-xl border overflow-hidden">
<!-- Customer Header -->
<div class="px-6 py-4 border-b border-brand-bg-light">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-full flex items-center justify-center overflow-hidden {{ 'bg-brand-highlight' if not item.profile_picture else '' }}">
{% if item.profile_picture %}
<img src="{{ item.profile_picture }}" alt="{{ item.customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
{% else %}
<span class="text-brand-bg-dark font-bold text-lg">{{ item.customer.name[0] | upper }}</span>
{% endif %}
</div>
<div>
<h3 class="font-semibold text-white text-lg">{{ item.customer.name }}</h3>
<p class="text-sm text-gray-400">{{ item.customer.company_name or 'Kein Unternehmen' }}</p>
</div>
</div>
<div class="flex items-center gap-2">
{% if item.status.ready_for_posts %}
<span class="px-3 py-1.5 bg-green-600/30 text-green-300 rounded-lg text-sm font-medium 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>
Bereit für Posts
</span>
{% else %}
<span class="px-3 py-1.5 bg-yellow-600/30 text-yellow-300 rounded-lg text-sm font-medium 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="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>
Setup unvollständig
</span>
{% endif %}
</div>
</div>
</div>
<!-- Status Grid -->
<div class="p-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<!-- Scraped Posts -->
<div class="bg-brand-bg/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
{% if item.status.has_scraped_posts %}
<svg class="w-5 h-5 text-green-500" 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>
{% else %}
<svg class="w-5 h-5 text-gray-500" 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>
{% endif %}
<span class="text-sm text-gray-400">Scraped Posts</span>
</div>
<p class="text-2xl font-bold text-white">{{ item.status.scraped_posts_count }}</p>
</div>
<!-- Profile Analysis -->
<div class="bg-brand-bg/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
{% if item.status.has_profile_analysis %}
<svg class="w-5 h-5 text-green-500" 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>
{% else %}
<svg class="w-5 h-5 text-gray-500" 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>
{% endif %}
<span class="text-sm text-gray-400">Profil Analyse</span>
</div>
<p class="text-lg font-semibold text-white">{{ 'Vorhanden' if item.status.has_profile_analysis else 'Fehlt' }}</p>
</div>
<!-- Research Topics -->
<div class="bg-brand-bg/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
{% if item.status.research_count > 0 %}
<svg class="w-5 h-5 text-green-500" 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>
{% else %}
<svg class="w-5 h-5 text-gray-500" 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>
{% endif %}
<span class="text-sm text-gray-400">Research Topics</span>
</div>
<p class="text-2xl font-bold text-white">{{ item.status.research_count }}</p>
</div>
<!-- Generated Posts -->
<div class="bg-brand-bg/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<span class="text-sm text-gray-400">Generierte Posts</span>
</div>
<p class="text-2xl font-bold text-white">{{ item.status.posts_count }}</p>
</div>
</div>
<!-- Missing Items -->
{% if item.status.missing_items %}
<div class="bg-yellow-900/20 border border-yellow-600/50 rounded-lg p-4">
<h4 class="font-medium text-yellow-300 mb-2 flex items-center gap-2">
<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 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>
Fehlende Elemente
</h4>
<ul class="space-y-1">
{% for item_missing in item.status.missing_items %}
<li class="text-yellow-200/80 text-sm 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="M9 5l7 7-7 7"/></svg>
{{ item_missing }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Quick Actions -->
<div class="flex flex-wrap gap-3 mt-4">
{% if not item.status.has_profile_analysis %}
<a href="/admin/customers/new" class="px-4 py-2 btn-primary rounded-lg text-sm transition-colors">
Setup wiederholen
</a>
{% endif %}
{% if item.status.research_count == 0 %}
<a href="/admin/research" class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-sm text-white transition-colors">
Recherche starten
</a>
{% endif %}
{% if item.status.ready_for_posts %}
<a href="/admin/create" class="px-4 py-2 btn-primary rounded-lg text-sm transition-colors">
Post erstellen
</a>
{% endif %}
<a href="/admin/impersonate/{{ item.customer.id }}" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm text-white 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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
Als User einloggen
</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card-bg rounded-xl border p-12 text-center">
<svg class="w-16 h-16 text-gray-600 mx-auto mb-4" 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>
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Kunden</h3>
<p class="text-gray-400 mb-6">Erstelle deinen ersten Kunden, um den Status zu sehen.</p>
<a href="/admin/customers/new" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg 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 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
Neuer Kunde
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -14,7 +14,7 @@
<!-- Customer Selection --> <!-- Customer Selection -->
<div> <div>
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label> <label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white"> <select name="user_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
<option value="">-- Kunde wählen --</option> <option value="">-- Kunde wählen --</option>
{% for customer in customers %} {% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option> <option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
@@ -229,7 +229,7 @@ customerSelect.addEventListener('change', async () => {
// Load post types // Load post types
try { try {
const ptResponse = await fetch(`/api/customers/${customerId}/post-types`); const ptResponse = await fetch(`/api/users/${customerId}/post-types`);
const ptData = await ptResponse.json(); const ptData = await ptResponse.json();
if (ptData.post_types && ptData.post_types.length > 0) { if (ptData.post_types && ptData.post_types.length > 0) {
@@ -258,7 +258,7 @@ customerSelect.addEventListener('change', async () => {
// Load topics // Load topics
try { try {
const response = await fetch(`/api/customers/${customerId}/topics`); const response = await fetch(`/api/users/${customerId}/topics`);
const data = await response.json(); const data = await response.json();
if (data.topics && data.topics.length > 0) { if (data.topics && data.topics.length > 0) {
@@ -317,7 +317,7 @@ async function loadTopicsForPostType(customerId, postTypeId) {
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>'; topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
try { try {
let url = `/api/customers/${customerId}/topics`; let url = `/api/users/${customerId}/topics`;
if (postTypeId) { if (postTypeId) {
url += `?post_type_id=${postTypeId}`; url += `?post_type_id=${postTypeId}`;
} }
@@ -437,7 +437,7 @@ form.addEventListener('submit', async (e) => {
postResult.innerHTML = '<p class="text-gray-400">Post wird generiert...</p>'; postResult.innerHTML = '<p class="text-gray-400">Post wird generiert...</p>';
const formData = new FormData(); const formData = new FormData();
formData.append('customer_id', customerId); formData.append('user_id', customerId);
formData.append('topic_json', JSON.stringify(topic)); formData.append('topic_json', JSON.stringify(topic));
if (selectedPostTypeIdInput.value) { if (selectedPostTypeIdInput.value) {
formData.append('post_type_id', selectedPostTypeIdInput.value); formData.append('post_type_id', selectedPostTypeIdInput.value);

View File

@@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Datenschutzerklärung - LinkedIn Workflow</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
max-width: 800px;
margin: 0 auto;
padding: 20px;
color: #333;
}
h1 { color: #0a66c2; }
h2 { color: #0a66c2; margin-top: 30px; }
.last-updated { color: #666; font-style: italic; }
</style>
</head>
<body>
<h1>Datenschutzerklärung / Privacy Policy</h1>
<p class="last-updated">Letzte Aktualisierung: {{ current_date }}</p>
<h2>1. Überblick</h2>
<p>
Diese Datenschutzerklärung beschreibt, wie LinkedIn Workflow ("wir", "uns", "unsere App")
Ihre Informationen sammelt, verwendet und schützt, wenn Sie unsere LinkedIn-Content-Scheduling-Plattform nutzen.
</p>
<h2>2. Welche Daten sammeln wir?</h2>
<h3>2.1 LinkedIn-Profildaten (via LinkedIn OAuth)</h3>
<ul>
<li>Ihr Name und LinkedIn-Profil-ID</li>
<li>Ihre E-Mail-Adresse</li>
<li>Ihr Profilbild (optional)</li>
<li>LinkedIn Access Token (verschlüsselt gespeichert)</li>
</ul>
<h3>2.2 Von Ihnen erstellte Inhalte</h3>
<ul>
<li>Entwürfe und geplante LinkedIn-Posts</li>
<li>Hochgeladene Bilder für Posts</li>
<li>Zeitpläne für Veröffentlichungen</li>
</ul>
<h3>2.3 Nutzungsdaten</h3>
<ul>
<li>Anmeldezeitpunkte</li>
<li>Erstellte und veröffentlichte Posts</li>
</ul>
<h2>3. Wie verwenden wir Ihre Daten?</h2>
<p>Wir verwenden Ihre Daten ausschließlich für folgende Zwecke:</p>
<ul>
<li><strong>Authentifizierung:</strong> Um Sie sicher anzumelden</li>
<li><strong>Content-Posting:</strong> Um Ihre genehmigten Posts auf LinkedIn zu veröffentlichen</li>
<li><strong>Content-Management:</strong> Um Ihre Post-Entwürfe und Zeitpläne zu speichern</li>
<li><strong>Service-Bereitstellung:</strong> Um die Kernfunktionen unserer App bereitzustellen</li>
</ul>
<h2>4. Datenspeicherung und Sicherheit</h2>
<ul>
<li><strong>Verschlüsselung:</strong> LinkedIn Access Tokens werden verschlüsselt gespeichert (AES-256)</li>
<li><strong>Sichere Datenbank:</strong> Alle Daten werden in einer sicheren Supabase-Datenbank gespeichert</li>
<li><strong>Zugriffskontrolle:</strong> Nur Sie können auf Ihre eigenen Daten zugreifen</li>
<li><strong>HTTPS:</strong> Alle Datenübertragungen erfolgen verschlüsselt über HTTPS</li>
</ul>
<h2>5. Datenweitergabe an Dritte</h2>
<p>Wir geben Ihre Daten <strong>NICHT</strong> an Dritte weiter, mit folgenden Ausnahmen:</p>
<ul>
<li><strong>LinkedIn:</strong> Wenn Sie einen Post planen, senden wir den Inhalt an LinkedIn's API, um ihn zu veröffentlichen (nur auf Ihre ausdrückliche Anweisung)</li>
<li><strong>Infrastruktur-Anbieter:</strong> Supabase (Datenbank-Hosting) und andere technische Dienstleister, die unsere App betreiben</li>
<li><strong>Gesetzliche Verpflichtungen:</strong> Nur wenn gesetzlich vorgeschrieben</li>
</ul>
<h2>6. Verwendung der LinkedIn-Daten</h2>
<p>
Unsere App verwendet die LinkedIn-API, um Posts in Ihrem Namen zu veröffentlichen.
Wir halten uns strikt an die
<a href="https://legal.linkedin.com/api-terms-of-use" target="_blank">LinkedIn API Terms of Use</a>.
</p>
<ul>
<li>Posts werden NUR veröffentlicht, wenn Sie dies ausdrücklich genehmigt haben</li>
<li>Wir posten KEINEN automatischen oder Spam-Inhalt</li>
<li>Sie behalten die volle Kontrolle über alle veröffentlichten Inhalte</li>
</ul>
<h2>7. Ihre Rechte</h2>
<p>Sie haben folgende Rechte bezüglich Ihrer Daten:</p>
<ul>
<li><strong>Zugriff:</strong> Sie können alle Ihre gespeicherten Daten jederzeit einsehen</li>
<li><strong>Bearbeitung:</strong> Sie können Ihre Daten jederzeit ändern oder löschen</li>
<li><strong>Löschung:</strong> Sie können Ihr Konto und alle zugehörigen Daten vollständig löschen</li>
<li><strong>Widerruf:</strong> Sie können die LinkedIn-Verbindung jederzeit in den Einstellungen trennen</li>
<li><strong>Export:</strong> Sie können Ihre Daten exportieren (auf Anfrage)</li>
</ul>
<h2>8. LinkedIn-Verbindung trennen</h2>
<p>
Sie können die LinkedIn-Verbindung jederzeit trennen durch:
</p>
<ul>
<li>Einstellungen → LinkedIn-Konto → "Verbindung trennen"</li>
<li>LinkedIn.com → Einstellungen → Apps → Unsere App entfernen</li>
</ul>
<p>
Nach der Trennung löschen wir Ihre LinkedIn Access Tokens sofort.
Ihre gespeicherten Post-Entwürfe bleiben erhalten (können aber nicht mehr veröffentlicht werden).
</p>
<h2>9. Cookies</h2>
<p>Wir verwenden folgende Cookies:</p>
<ul>
<li><strong>Session-Cookie:</strong> Zur Authentifizierung (erforderlich)</li>
<li><strong>OAuth-State-Cookie:</strong> Zur sicheren LinkedIn-Authentifizierung (temporär)</li>
</ul>
<h2>10. Kinder</h2>
<p>
Unsere App richtet sich nicht an Personen unter 16 Jahren.
Wir sammeln wissentlich keine Daten von Kindern.
</p>
<h2>11. Änderungen dieser Datenschutzerklärung</h2>
<p>
Wir können diese Datenschutzerklärung gelegentlich aktualisieren.
Änderungen werden auf dieser Seite veröffentlicht und das "Letzte Aktualisierung"-Datum wird aktualisiert.
</p>
<h2>12. Kontakt</h2>
<p>
Bei Fragen zu dieser Datenschutzerklärung oder Ihren Daten kontaktieren Sie uns bitte:
</p>
<p>
<strong>E-Mail:</strong> [IHRE-EMAIL-ADRESSE]<br>
</p>
<hr>
<h1>Privacy Policy (English)</h1>
<p class="last-updated">Last Updated: {{ current_date }}</p>
<h2>1. Overview</h2>
<p>
This Privacy Policy describes how LinkedIn Workflow ("we", "us", "our app")
collects, uses, and protects your information when you use our LinkedIn content scheduling platform.
</p>
<h2>2. What Data We Collect</h2>
<h3>2.1 LinkedIn Profile Data (via LinkedIn OAuth)</h3>
<ul>
<li>Your name and LinkedIn profile ID</li>
<li>Your email address</li>
<li>Your profile picture (optional)</li>
<li>LinkedIn access token (encrypted)</li>
</ul>
<h3>2.2 Content You Create</h3>
<ul>
<li>Draft and scheduled LinkedIn posts</li>
<li>Uploaded images for posts</li>
<li>Publishing schedules</li>
</ul>
<h3>2.3 Usage Data</h3>
<ul>
<li>Login timestamps</li>
<li>Created and published posts</li>
</ul>
<h2>3. How We Use Your Data</h2>
<p>We use your data exclusively for:</p>
<ul>
<li><strong>Authentication:</strong> To securely log you in</li>
<li><strong>Content Posting:</strong> To publish your approved posts to LinkedIn</li>
<li><strong>Content Management:</strong> To store your post drafts and schedules</li>
<li><strong>Service Provision:</strong> To provide our app's core functionality</li>
</ul>
<h2>4. Data Storage and Security</h2>
<ul>
<li><strong>Encryption:</strong> LinkedIn access tokens are encrypted (AES-256)</li>
<li><strong>Secure Database:</strong> All data stored in secure Supabase database</li>
<li><strong>Access Control:</strong> Only you can access your own data</li>
<li><strong>HTTPS:</strong> All data transmissions encrypted via HTTPS</li>
</ul>
<h2>5. Data Sharing</h2>
<p>We do <strong>NOT</strong> share your data with third parties, except:</p>
<ul>
<li><strong>LinkedIn:</strong> When you schedule a post, we send content to LinkedIn's API to publish it (only at your explicit request)</li>
<li><strong>Infrastructure Providers:</strong> Supabase (database hosting) and other technical service providers</li>
<li><strong>Legal Obligations:</strong> Only when legally required</li>
</ul>
<h2>6. LinkedIn Data Usage</h2>
<p>
Our app uses the LinkedIn API to post on your behalf.
We strictly comply with
<a href="https://legal.linkedin.com/api-terms-of-use" target="_blank">LinkedIn API Terms of Use</a>.
</p>
<ul>
<li>Posts are ONLY published when you explicitly approve them</li>
<li>We do NOT post automatic or spam content</li>
<li>You maintain full control over all published content</li>
</ul>
<h2>7. Your Rights</h2>
<p>You have the following rights regarding your data:</p>
<ul>
<li><strong>Access:</strong> View all your stored data at any time</li>
<li><strong>Edit:</strong> Modify or delete your data at any time</li>
<li><strong>Deletion:</strong> Completely delete your account and all associated data</li>
<li><strong>Revoke:</strong> Disconnect LinkedIn connection in settings at any time</li>
<li><strong>Export:</strong> Request data export</li>
</ul>
<h2>8. Disconnect LinkedIn</h2>
<p>You can disconnect LinkedIn at any time via:</p>
<ul>
<li>Settings → LinkedIn Account → "Disconnect"</li>
<li>LinkedIn.com → Settings → Apps → Remove our app</li>
</ul>
<p>
After disconnection, we immediately delete your LinkedIn access tokens.
Your saved post drafts remain (but cannot be published).
</p>
<h2>9. Cookies</h2>
<p>We use the following cookies:</p>
<ul>
<li><strong>Session Cookie:</strong> For authentication (required)</li>
<li><strong>OAuth State Cookie:</strong> For secure LinkedIn authentication (temporary)</li>
</ul>
<h2>10. Children</h2>
<p>
Our app is not directed at persons under 16 years old.
We do not knowingly collect data from children.
</p>
<h2>11. Changes to Privacy Policy</h2>
<p>
We may update this Privacy Policy occasionally.
Changes will be posted on this page with an updated "Last Updated" date.
</p>
<h2>12. Contact</h2>
<p>
For questions about this Privacy Policy or your data, please contact us:
</p>
<p>
<strong>Email:</strong> [YOUR-EMAIL-ADDRESS]<br>
</p>
</body>
</html>

View File

@@ -13,7 +13,7 @@
<form id="researchForm" class="card-bg rounded-xl border p-6"> <form id="researchForm" class="card-bg rounded-xl border p-6">
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label> <label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white"> <select name="user_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
<option value="">-- Kunde wählen --</option> <option value="">-- Kunde wählen --</option>
{% for customer in customers %} {% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option> <option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
@@ -96,7 +96,7 @@ customerSelect.addEventListener('change', async () => {
} }
try { try {
const response = await fetch(`/api/customers/${customerId}/post-types`); const response = await fetch(`/api/users/${customerId}/post-types`);
const data = await response.json(); const data = await response.json();
if (data.post_types && data.post_types.length > 0) { if (data.post_types && data.post_types.length > 0) {
@@ -150,7 +150,7 @@ form.addEventListener('submit', async (e) => {
progressArea.classList.remove('hidden'); progressArea.classList.remove('hidden');
const formData = new FormData(); const formData = new FormData();
formData.append('customer_id', customerId); formData.append('user_id', customerId);
if (selectedPostTypeId.value) { if (selectedPostTypeId.value) {
formData.append('post_type_id', selectedPostTypeId.value); formData.append('post_type_id', selectedPostTypeId.value);
} }

View File

@@ -210,7 +210,7 @@ customerSelect.addEventListener('change', async () => {
async function loadCustomerData(customerId) { async function loadCustomerData(customerId) {
// Load post types // Load post types
try { try {
const ptResponse = await fetch(`/api/customers/${customerId}/post-types`); const ptResponse = await fetch(`/api/users/${customerId}/post-types`);
const ptData = await ptResponse.json(); const ptData = await ptResponse.json();
currentPostTypes = ptData.post_types || []; currentPostTypes = ptData.post_types || [];
@@ -231,7 +231,7 @@ async function loadCustomerData(customerId) {
// Load posts // Load posts
try { try {
const response = await fetch(`/api/customers/${customerId}/linkedin-posts`); const response = await fetch(`/api/users/${customerId}/linkedin-posts`);
const data = await response.json(); const data = await response.json();
console.log('API Response:', data); console.log('API Response:', data);
@@ -484,7 +484,7 @@ classifyAllBtn.addEventListener('click', async () => {
progressArea.classList.remove('hidden'); progressArea.classList.remove('hidden');
try { try {
const response = await fetch(`/api/customers/${customerId}/classify-posts`, { const response = await fetch(`/api/users/${customerId}/classify-posts`, {
method: 'POST' method: 'POST'
}); });
const data = await response.json(); const data = await response.json();
@@ -512,7 +512,7 @@ analyzeTypesBtn.addEventListener('click', async () => {
progressArea.classList.remove('hidden'); progressArea.classList.remove('hidden');
try { try {
const response = await fetch(`/api/customers/${customerId}/analyze-post-types`, { const response = await fetch(`/api/users/${customerId}/analyze-post-types`, {
method: 'POST' method: 'POST'
}); });
const data = await response.json(); const data = await response.json();

View File

@@ -57,14 +57,20 @@
<div class="flex items-center gap-3"> <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"> <div class="w-10 h-10 rounded-full overflow-hidden bg-brand-highlight flex items-center justify-center">
{% if session.linkedin_picture %} {% 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 %} {% 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 %} {% endif %}
</div> </div>
<div class="flex-1 min-w-0"> <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-white font-medium text-sm truncate">{{ session.display_name or session.linkedin_name or 'Benutzer' }}</p>
<p class="text-gray-400 text-xs truncate">{{ session.customer_name }}</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> </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> <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 Meine Posts
</a> </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 %}"> <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> <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 Status
</a> </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> </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"> <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> <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 Logout
@@ -108,6 +136,122 @@
</div> </div>
</main> </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 %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -0,0 +1,185 @@
{% extends "base.html" %}
{% block title %}Mitarbeiter verwalten{% endblock %}
{% block content %}
<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" class="btn-primary py-2 px-6 rounded-lg transition-colors">
Einladung senden
</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.linkedin_name or employee.email)[0] | upper }}</span>
{% endif %}
</div>
<div>
<p class="text-white font-medium">{{ 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">
<span class="text-xs px-2 py-1 rounded-full
{% if employee.onboarding_status == 'completed' or employee.onboarding_status.value == 'completed' %}
bg-green-900/50 text-green-300
{% else %}
bg-yellow-900/50 text-yellow-300
{% endif %}">
{% if employee.onboarding_status == 'completed' or employee.onboarding_status.value == '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>
{% endblock %}
{% block scripts %}
<script>
// Send invitation
document.getElementById('invite-form').addEventListener('submit', async function(e) {
e.preventDefault();
const email = document.getElementById('invite-email').value;
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) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (e) {
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 %}

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 %}

View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ session.company_name or 'Unternehmen' }} - LinkedIn Posts{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'brand': {
'bg': '#3d4848',
'bg-light': '#4a5858',
'bg-dark': '#2d3838',
'highlight': '#ffc700',
'highlight-dark': '#e6b300',
}
}
}
}
}
</script>
<style>
body { background-color: #3d4848; }
.nav-link.active { background-color: #ffc700; color: #2d3838; }
.nav-link.active svg { stroke: #2d3838; }
.btn-primary { background-color: #ffc700; color: #2d3838; }
.btn-primary:hover { background-color: #e6b300; }
.sidebar-bg { background-color: #2d3838; }
.card-bg { background-color: #4a5858; border-color: #5a6868; }
.input-bg { background-color: #3d4848; border-color: #5a6868; }
.input-bg:focus { border-color: #ffc700; outline: none; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #3d4848; }
::-webkit-scrollbar-thumb { background: #5a6868; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #6a7878; }
</style>
{% block head %}{% endblock %}
</head>
<body class="text-gray-100 min-h-screen flex">
<!-- Sidebar -->
<aside class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full">
<div class="p-4 border-b border-gray-600">
<div class="flex items-center justify-center gap-3">
<div>
<img src="/static/logo.png" alt="Logo" class="h-15 w-auto">
</div>
</div>
</div>
<!-- Company Profile -->
{% if session %}
<div class="p-4 border-b border-gray-600">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg overflow-hidden bg-brand-highlight flex items-center justify-center">
<svg class="w-6 h-6 text-brand-bg-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-white font-medium text-sm truncate">{{ session.company_name or 'Unternehmen' }}</p>
<p class="text-gray-400 text-xs truncate">{{ session.email or '' }}</p>
</div>
</div>
</div>
{% endif %}
<nav class="flex-1 p-4 space-y-2">
<a href="/" 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 == 'home' %}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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
Dashboard
</a>
<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>
Mitarbeiter
</a>
<a href="/company/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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Strategie
</a>
<div class="pt-4 mt-4 border-t border-gray-600">
<p class="px-4 text-xs text-gray-500 uppercase tracking-wider mb-2">Mitarbeiter-Aktionen</p>
<a href="/company/manage" 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 == 'manage' %}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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
Inhalte verwalten
</a>
<a href="/company/calendar" 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 == 'calendar' %}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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
Posting-Kalender
</a>
</div>
</nav>
<div class="p-4 border-t border-gray-600 space-y-2">
<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
</a>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 ml-64">
<div class="p-8">
{% block content %}{% endblock %}
</div>
</main>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,668 @@
{% extends "company_base.html" %}
{% block title %}Posting-Kalender - {{ session.company_name }}{% endblock %}
{% block head %}
<style>
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: #5a6868;
}
.calendar-cell {
background: #4a5858;
min-height: 120px;
padding: 8px;
}
.calendar-cell.other-month {
background: #3d4848;
opacity: 0.5;
}
.calendar-cell.today {
border: 2px solid #ffc700;
}
.post-chip {
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
margin-bottom: 4px;
cursor: pointer;
transition: transform 0.1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.post-chip:hover {
transform: scale(1.02);
}
.post-chip.scheduled {
background: #ffc700;
color: #2d3838;
}
.post-chip.published {
background: #6b7280;
color: #9ca3af;
opacity: 0.6;
cursor: pointer;
}
.post-chip.approved {
background: #3b82f6;
color: white;
}
.employee-color-1 { border-left: 3px solid #f472b6; }
.employee-color-2 { border-left: 3px solid #60a5fa; }
.employee-color-3 { border-left: 3px solid #34d399; }
.employee-color-4 { border-left: 3px solid #fbbf24; }
.employee-color-5 { border-left: 3px solid #a78bfa; }
.time-slot {
font-size: 10px;
color: #9ca3af;
margin-bottom: 2px;
}
.view-toggle .active {
background: #ffc700;
color: #2d3838;
}
/* Timeline Week View */
.timeline-header {
display: grid;
grid-template-columns: 50px repeat(7, 1fr);
background: #3d4848;
}
.timeline-header-cell {
padding: 8px 4px;
text-align: center;
font-size: 13px;
color: #9ca3af;
border-left: 1px solid #5a6868;
}
.timeline-header-cell.today {
color: #ffc700;
font-weight: bold;
}
.timeline-grid {
display: grid;
grid-template-columns: 50px repeat(7, 1fr);
position: relative;
}
.timeline-hour-label {
font-size: 11px;
color: #6b7280;
text-align: right;
padding-right: 8px;
height: 60px;
line-height: 1;
padding-top: 0;
transform: translateY(-6px);
}
.timeline-day-col {
position: relative;
height: 60px;
border-top: 1px solid #5a6868;
border-left: 1px solid #5a6868;
}
.timeline-day-col:hover {
background: rgba(255, 199, 0, 0.03);
}
.timeline-day-col.past-slot {
cursor: default !important;
opacity: 0.4;
}
.timeline-day-col.past-slot:hover {
background: none;
}
.calendar-cell .schedule-btn.past-slot {
display: none !important;
}
.timeline-post {
position: absolute;
left: 2px;
right: 2px;
height: 36px;
border-radius: 4px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
z-index: 5;
transition: transform 0.1s, z-index 0s;
line-height: 1.4;
}
.timeline-post:hover {
transform: scale(1.03);
z-index: 20;
}
.timeline-post.scheduled {
background: #ffc700;
color: #2d3838;
}
.timeline-post.approved {
background: #3b82f6;
color: white;
}
.timeline-post.published {
background: #374151;
color: #9ca3af;
border: 1px solid #6b7280;
}
.timeline-post .timeline-post-time {
font-weight: 600;
font-size: 10px;
}
.timeline-post .timeline-post-title {
font-size: 10px;
opacity: 0.9;
}
</style>
{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Posting-Kalender</h1>
<p class="text-gray-400 mt-1">Plane und verwalte Posts aller Mitarbeiter</p>
</div>
<div class="flex items-center gap-4">
<!-- View Toggle -->
<div class="view-toggle flex rounded-lg overflow-hidden border border-gray-600">
<a href="/company/calendar?month={{ month }}&year={{ year }}&view=month"
class="px-3 py-1.5 text-sm transition-colors {% if view == 'month' %}active{% else %}text-gray-300 hover:bg-brand-bg-dark{% endif %}">
Monat
</a>
<a href="/company/calendar?view=week&month={{ month }}&year={{ year }}"
class="px-3 py-1.5 text-sm transition-colors {% if view == 'week' %}active{% else %}text-gray-300 hover:bg-brand-bg-dark{% endif %}">
Woche
</a>
</div>
<!-- Navigation -->
<div class="flex items-center gap-2">
{% if view == 'week' %}
<a href="/company/calendar?view=week&week_start={{ prev_week_start }}&month={{ month }}&year={{ year }}" class="p-2 rounded-lg bg-brand-bg-dark hover:bg-brand-bg-light transition-colors">
<svg class="w-5 h-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<span class="text-white font-medium min-w-[200px] text-center">{{ week_label }}</span>
<a href="/company/calendar?view=week&week_start={{ next_week_start }}&month={{ month }}&year={{ year }}" class="p-2 rounded-lg bg-brand-bg-dark hover:bg-brand-bg-light transition-colors">
<svg class="w-5 h-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
{% else %}
<a href="/company/calendar?month={{ prev_month }}&year={{ prev_year }}&view=month" class="p-2 rounded-lg bg-brand-bg-dark hover:bg-brand-bg-light transition-colors">
<svg class="w-5 h-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<span class="text-white font-medium min-w-[150px] text-center">{{ month_name }} {{ year }}</span>
<a href="/company/calendar?month={{ next_month }}&year={{ next_year }}&view=month" class="p-2 rounded-lg bg-brand-bg-dark hover:bg-brand-bg-light transition-colors">
<svg class="w-5 h-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
{% endif %}
</div>
{% if view == 'week' %}
<a href="/company/calendar?view=week" class="px-4 py-2 text-sm bg-brand-bg-dark hover:bg-brand-bg-light rounded-lg text-gray-300 transition-colors">
Heute
</a>
{% else %}
<a href="/company/calendar?month={{ current_month }}&year={{ current_year }}" class="px-4 py-2 text-sm bg-brand-bg-dark hover:bg-brand-bg-light rounded-lg text-gray-300 transition-colors">
Heute
</a>
{% endif %}
</div>
</div>
<!-- Employee Legend -->
{% if employees %}
<div class="card-bg border rounded-xl p-4 mb-6">
<p class="text-sm text-gray-400 mb-3">Mitarbeiter</p>
<div class="flex flex-wrap gap-3">
{% for emp in employees %}
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded employee-color-{{ loop.index % 5 + 1 }}" style="background: currentColor;"></div>
<span class="text-sm text-white">{{ emp.name }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Calendar -->
{% if view == 'week' %}
<!-- Week Timeline View -->
<div class="card-bg border rounded-xl overflow-hidden">
<!-- Day Headers -->
<div class="timeline-header">
<div class="timeline-header-cell" style="border-left: none;"></div>
{% for day in calendar_weeks[0] %}
<div class="timeline-header-cell {% if day.is_today %}today{% endif %}">
{{ day.full_date }}
</div>
{% endfor %}
</div>
<!-- Timeline Grid -->
<div class="timeline-grid" id="timelineGrid">
{% for hour in range(0, 24) %}
<!-- Hour row -->
<div class="timeline-hour-label">{{ '%02d'|format(hour) }}:00</div>
{% for day in calendar_weeks[0] %}
<div class="timeline-day-col"
data-date="{{ day.date }}" data-hour="{{ hour }}"
onclick="openScheduleModal('{{ day.date }}', '{{ '%02d'|format(hour) }}:00')"
style="cursor: pointer;">
{% for post in day.posts %}
{% set post_hour = post.time[:2]|int %}
{% if post_hour == hour %}
<div class="timeline-post {{ post.status }} employee-color-{{ post.employee_index % 5 + 1 }}"
onclick="event.stopPropagation(); openPostModal('{{ post.id }}')"
title="{{ post.employee_name }}: {{ post.topic_title }}"
data-day="{{ day.date }}"
data-abs-min="{{ post.time[:2]|int * 60 + post.time[3:]|int }}"
style="top: {% if post.status == 'published' %}{{ post.time[3:]|int - 36 }}{% else %}{{ post.time[3:]|int }}{% endif %}px;">
<div class="timeline-post-time">{{ post.time }}</div>
<div class="timeline-post-title">{{ post.topic_title[:15] }}{% if post.topic_title|length > 15 %}...{% endif %}</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
{% endfor %}
</div>
</div>
{% else %}
<!-- Month Grid View -->
<div class="card-bg border rounded-xl overflow-hidden">
<!-- Weekday Headers -->
<div class="grid grid-cols-7 bg-brand-bg-dark">
<div class="p-3 text-center text-gray-400 text-sm font-medium">Mo</div>
<div class="p-3 text-center text-gray-400 text-sm font-medium">Di</div>
<div class="p-3 text-center text-gray-400 text-sm font-medium">Mi</div>
<div class="p-3 text-center text-gray-400 text-sm font-medium">Do</div>
<div class="p-3 text-center text-gray-400 text-sm font-medium">Fr</div>
<div class="p-3 text-center text-gray-400 text-sm font-medium">Sa</div>
<div class="p-3 text-center text-gray-400 text-sm font-medium">So</div>
</div>
<!-- Calendar Grid -->
<div class="calendar-grid">
{% for week in calendar_weeks %}
{% for day in week %}
<div class="calendar-cell {% if day.other_month %}other-month{% endif %} {% if day.is_today %}today{% endif %}">
<div class="text-sm {% if day.is_today %}text-brand-highlight font-bold{% else %}text-gray-400{% endif %} mb-2">
{{ day.day }}
</div>
<!-- Posts for this day -->
{% for post in day.posts %}
<div class="post-chip {{ post.status }} employee-color-{{ post.employee_index % 5 + 1 }}"
onclick="openPostModal('{{ post.id }}')"
title="{{ post.employee_name }}: {{ post.topic_title }}">
<span class="time-slot">{{ post.time }}</span>
{{ post.topic_title[:20] }}{% if post.topic_title|length > 20 %}...{% endif %}
</div>
{% endfor %}
<!-- Add post button for empty slots -->
{% if not day.other_month %}
<button onclick="openScheduleModal('{{ day.date }}')"
data-date="{{ day.date }}"
class="schedule-btn w-full mt-1 p-1 text-xs text-gray-500 hover:text-brand-highlight hover:bg-brand-bg-dark rounded transition-colors opacity-0 hover:opacity-100">
+ Post planen
</button>
{% endif %}
</div>
{% endfor %}
{% endfor %}
</div>
</div>
{% endif %}
<!-- Unscheduled Posts -->
{% if unscheduled_posts %}
<div class="card-bg border rounded-xl p-6 mt-6">
<h2 class="text-lg font-semibold text-white mb-4">Nicht geplante Posts (bereit zum Einplanen)</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for post in unscheduled_posts %}
<div class="bg-brand-bg-dark rounded-lg p-4" draggable="true" data-post-id="{{ post.id }}">
<div class="flex items-start justify-between mb-2">
<span class="text-xs px-2 py-1 rounded bg-blue-500/20 text-blue-400">{{ post.employee_name }}</span>
<span class="text-xs text-gray-500">{{ post.created_at.strftime('%d.%m.') }}</span>
</div>
<p class="text-white text-sm font-medium mb-1">{{ post.topic_title }}</p>
<p class="text-gray-400 text-xs line-clamp-2">{{ post.post_content[:100] }}...</p>
<button onclick="openScheduleModalForPost('{{ post.id }}', '{{ post.topic_title|e }}')"
class="mt-3 w-full py-2 text-xs bg-brand-highlight text-brand-bg-dark rounded hover:bg-brand-highlight-dark transition-colors">
Einplanen
</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Schedule Modal -->
<div id="scheduleModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-brand-bg-light rounded-xl p-6 w-full max-w-md mx-4">
<h3 class="text-lg font-semibold text-white mb-4">Post einplanen</h3>
<form id="scheduleForm" onsubmit="submitSchedule(event)">
<input type="hidden" id="schedulePostId" name="post_id">
<!-- Select Post (if no post pre-selected) -->
<div id="postSelectContainer" class="mb-4">
<label class="block text-sm text-gray-300 mb-2">Post auswahlen</label>
<select id="schedulePostSelect" name="post_id_select" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
<option value="">-- Post auswahlen --</option>
{% for post in unscheduled_posts %}
<option value="{{ post.id }}">{{ post.employee_name }}: {{ post.topic_title[:40] }}...</option>
{% endfor %}
</select>
</div>
<!-- Date -->
<div class="mb-4">
<label class="block text-sm text-gray-300 mb-2">Datum</label>
<input type="date" id="scheduleDate" name="date" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white">
</div>
<!-- Time -->
<div class="mb-6">
<label class="block text-sm text-gray-300 mb-2">Uhrzeit</label>
<input type="time" id="scheduleTime" name="time" value="09:00" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white">
</div>
<div class="flex gap-3">
<button type="button" onclick="closeScheduleModal()" class="flex-1 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-500 transition-colors">
Abbrechen
</button>
<button type="submit" class="flex-1 py-2 bg-brand-highlight text-brand-bg-dark rounded-lg hover:bg-brand-highlight-dark transition-colors">
Einplanen
</button>
</div>
</form>
</div>
</div>
<!-- Post Detail Modal -->
<div id="postModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-brand-bg-light rounded-xl p-6 w-full max-w-lg mx-4">
<div class="flex items-start justify-between mb-4">
<h3 id="postModalTitle" class="text-lg font-semibold text-white">Post Details</h3>
<button onclick="closePostModal()" class="text-gray-400 hover:text-white">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div id="postModalContent" class="text-gray-300">
<!-- Filled via JS -->
</div>
<div id="postModalActions" class="mt-6 flex gap-3">
<!-- Filled via JS -->
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function isInPast(date, time) {
const now = new Date();
const check = time ? new Date(date + 'T' + time) : new Date(date + 'T23:59:59');
return check < now;
}
function openScheduleModal(date, time) {
if (isInPast(date, time)) return;
document.getElementById('schedulePostId').value = '';
document.getElementById('postSelectContainer').style.display = 'block';
document.getElementById('scheduleDate').value = date;
if (time) {
document.getElementById('scheduleTime').value = time;
}
document.getElementById('scheduleModal').classList.remove('hidden');
document.getElementById('scheduleModal').classList.add('flex');
}
function openScheduleModalForPost(postId, title) {
document.getElementById('schedulePostId').value = postId;
document.getElementById('postSelectContainer').style.display = 'none';
// Set default date to today
const today = new Date().toISOString().split('T')[0];
document.getElementById('scheduleDate').value = today;
document.getElementById('scheduleModal').classList.remove('hidden');
document.getElementById('scheduleModal').classList.add('flex');
}
function closeScheduleModal() {
document.getElementById('scheduleModal').classList.add('hidden');
document.getElementById('scheduleModal').classList.remove('flex');
}
async function submitSchedule(event) {
event.preventDefault();
const postId = document.getElementById('schedulePostId').value || document.getElementById('schedulePostSelect').value;
const date = document.getElementById('scheduleDate').value;
const time = document.getElementById('scheduleTime').value;
if (!postId) {
alert('Bitte wahlen Sie einen Post aus');
return;
}
if (isInPast(date, time)) {
alert('Der gewählte Zeitpunkt liegt in der Vergangenheit.');
return;
}
try {
// Convert local time to UTC for backend
const localDateTime = new Date(`${date}T${time}:00`);
const utcDateTime = localDateTime.toISOString();
const formData = new FormData();
formData.append('scheduled_at', utcDateTime);
const response = await fetch(`/api/posts/${postId}/schedule`, {
method: 'POST',
body: formData,
credentials: 'same-origin'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Fehler beim Einplanen');
}
window.location.reload();
} catch (error) {
alert('Fehler: ' + error.message);
}
}
async function openPostModal(postId) {
document.getElementById('postModal').classList.remove('hidden');
document.getElementById('postModal').classList.add('flex');
document.getElementById('postModalContent').innerHTML = '<p class="text-gray-400">Laden...</p>';
document.getElementById('postModalActions').innerHTML = '';
// Fetch post details
try {
const response = await fetch(`/api/posts/${postId}`, {
credentials: 'same-origin'
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const post = await response.json();
document.getElementById('postModalTitle').textContent = post.topic_title || 'Post Details';
const scheduledAt = post.scheduled_at ? new Date(post.scheduled_at).toLocaleString('de-DE') : 'Nicht geplant';
const statusLabel = {
'scheduled': 'Geplant',
'published': 'Veroffentlicht',
'approved': 'Bereit',
'draft': 'Entwurf'
}[post.status] || post.status;
document.getElementById('postModalContent').innerHTML = `
<div class="mb-4">
<span class="text-xs px-2 py-1 rounded ${post.status === 'scheduled' ? 'bg-yellow-500/20 text-yellow-400' : post.status === 'published' ? 'bg-green-500/20 text-green-400' : 'bg-blue-500/20 text-blue-400'}">
${statusLabel}
</span>
${post.status === 'scheduled' ? `<span class="text-xs text-gray-400 ml-2">${scheduledAt}</span>` : ''}
</div>
<div class="bg-brand-bg-dark rounded-lg p-4 max-h-64 overflow-y-auto">
<p class="text-sm whitespace-pre-wrap">${post.post_content}</p>
</div>
`;
let actionsHtml = `
<a href="/company/manage/post/${postId}" class="flex-1 py-2 text-center bg-brand-bg-dark text-white rounded-lg hover:bg-gray-600 transition-colors">
Details ansehen
</a>
`;
if (post.status === 'scheduled') {
actionsHtml += `
<button onclick="unschedulePost('${postId}')" class="flex-1 py-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors">
Planung aufheben
</button>
`;
} else if (post.status === 'approved') {
actionsHtml += `
<button onclick="closePostModal(); openScheduleModalForPost('${postId}', '')" class="flex-1 py-2 bg-brand-highlight text-brand-bg-dark rounded-lg hover:bg-brand-highlight-dark transition-colors">
Einplanen
</button>
`;
}
document.getElementById('postModalActions').innerHTML = actionsHtml;
} catch (error) {
console.error('Error loading post:', error);
document.getElementById('postModalContent').innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
}
}
function closePostModal() {
document.getElementById('postModal').classList.add('hidden');
document.getElementById('postModal').classList.remove('flex');
}
async function unschedulePost(postId) {
if (!confirm('Mochten Sie die Planung dieses Posts wirklich aufheben?')) return;
try {
const response = await fetch(`/api/posts/${postId}/unschedule`, {
method: 'POST',
credentials: 'same-origin'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Fehler beim Aufheben der Planung');
}
window.location.reload();
} catch (error) {
alert('Fehler: ' + error.message);
}
}
// Close modals on outside click
document.getElementById('scheduleModal').addEventListener('click', function(e) {
if (e.target === this) closeScheduleModal();
});
document.getElementById('postModal').addEventListener('click', function(e) {
if (e.target === this) closePostModal();
});
// Layout overlapping timeline posts side by side
(function() {
const grid = document.getElementById('timelineGrid');
if (!grid) return;
const POST_HEIGHT = 36;
const dayPosts = {};
grid.querySelectorAll('.timeline-post').forEach(el => {
const day = el.dataset.day;
const absMin = parseInt(el.dataset.absMin);
const isPublished = el.classList.contains('published');
const top = isPublished ? absMin - POST_HEIGHT : absMin;
if (!dayPosts[day]) dayPosts[day] = [];
dayPosts[day].push({ el, top, bottom: top + POST_HEIGHT });
});
Object.values(dayPosts).forEach(posts => {
if (posts.length <= 1) return;
posts.sort((a, b) => a.top - b.top);
// Group overlapping posts
const groups = [];
let group = [posts[0]];
for (let i = 1; i < posts.length; i++) {
const overlaps = group.some(p => posts[i].top < p.bottom && posts[i].bottom > p.top);
if (overlaps) {
group.push(posts[i]);
} else {
groups.push(group);
group = [posts[i]];
}
}
groups.push(group);
groups.forEach(g => {
if (g.length <= 1) return;
const w = 100 / g.length;
g.forEach((p, idx) => {
p.el.style.left = (idx * w) + '%';
p.el.style.width = w + '%';
p.el.style.right = 'auto';
});
});
});
})();
// Dim past slots and hide past schedule buttons
(function() {
const now = new Date();
const todayStr = now.toISOString().split('T')[0];
const currentHour = now.getHours();
// Week view: dim past hour cells
document.querySelectorAll('.timeline-day-col').forEach(cell => {
const date = cell.dataset.date;
const hour = parseInt(cell.dataset.hour);
if (date < todayStr || (date === todayStr && hour <= currentHour)) {
cell.classList.add('past-slot');
cell.removeAttribute('onclick');
}
});
// Month view: hide "+ Post planen" for past dates
document.querySelectorAll('.schedule-btn').forEach(btn => {
const date = btn.dataset.date;
if (date < todayStr) {
btn.classList.add('past-slot');
}
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,180 @@
{% extends "company_base.html" %}
{% block title %}Dashboard - {{ session.company_name }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-white mb-6">Dashboard</h1>
<!-- Stats Cards -->
<div class="grid md:grid-cols-3 gap-6 mb-8">
<!-- Employees Card -->
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-brand-highlight" 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>
<div>
{% if license_key %}
<p class="text-3xl font-bold text-white">{{ total_employees }}<span class="text-xl text-gray-500">/{{ license_key.max_employees }}</span></p>
<p class="text-gray-400 text-sm">Mitarbeiter</p>
<p class="text-xs text-gray-500 mt-1">
{% set remaining = license_key.max_employees - total_employees %}
{% if remaining > 0 %}
Noch {{ remaining }} verfügbar
{% else %}
<span class="text-yellow-400">Limit erreicht</span>
{% endif %}
</p>
{% else %}
<p class="text-3xl font-bold text-white">{{ total_employees }}</p>
<p class="text-gray-400 text-sm">Mitarbeiter</p>
<p class="text-xs text-green-400 mt-1">Unbegrenzt</p>
{% endif %}
</div>
</div>
</div>
<!-- Pending Invitations Card -->
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-400" 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>
</div>
<div>
<p class="text-3xl font-bold text-white">{{ pending_invitations | length }}</p>
<p class="text-gray-400 text-sm">Offene Einladungen</p>
</div>
</div>
</div>
<!-- Strategy Status Card -->
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 {% if company.company_strategy %}bg-green-500/20{% else %}bg-yellow-500/20{% endif %} rounded-lg flex items-center justify-center">
{% if company.company_strategy and company.company_strategy.mission %}
<svg class="w-6 h-6 text-green-400" 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>
{% else %}
<svg class="w-6 h-6 text-yellow-400" 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>
{% endif %}
</div>
<div>
{% if company.company_strategy and company.company_strategy.mission %}
<p class="text-lg font-bold text-green-400">Aktiv</p>
<p class="text-gray-400 text-sm">Strategie definiert</p>
{% else %}
<p class="text-lg font-bold text-yellow-400">Ausstehend</p>
<p class="text-gray-400 text-sm">Strategie fehlt</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Daily Quota Section -->
{% if quota and license_key %}
<div class="card-bg border rounded-xl p-6 mb-8">
<h2 class="text-lg font-semibold text-white mb-4">Tägliche Limits</h2>
<div class="grid md:grid-cols-2 gap-4">
<!-- Posts Quota -->
<div class="bg-brand-bg/30 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-400">Posts heute</span>
<span class="text-xs text-gray-500">{{ quota.posts_created | default(0) }}/{{ license_key.max_posts_per_day }}</span>
</div>
<div class="w-full bg-brand-bg rounded-full h-2">
{% set posts_pct = ((quota.posts_created / license_key.max_posts_per_day * 100) if license_key.max_posts_per_day > 0 else 0) | round %}
<div class="bg-brand-highlight h-2 rounded-full transition-all"
style="width: {{ posts_pct }}%"></div>
</div>
</div>
<!-- Researches Quota -->
<div class="bg-brand-bg/30 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-400">Researches heute</span>
<span class="text-xs text-gray-500">{{ quota.researches_created | default(0) }}/{{ license_key.max_researches_per_day }}</span>
</div>
<div class="w-full bg-brand-bg rounded-full h-2">
{% set researches_pct = ((quota.researches_created / license_key.max_researches_per_day * 100) if license_key.max_researches_per_day > 0 else 0) | round %}
<div class="bg-blue-500 h-2 rounded-full transition-all"
style="width: {{ researches_pct }}%"></div>
</div>
</div>
</div>
<p class="text-xs text-gray-500 mt-3">
Limits werden täglich um Mitternacht zurückgesetzt. (Lizenz: {{ license_key.key }})
</p>
</div>
{% elif quota %}
<!-- No license key - show info message -->
<div class="bg-blue-900/50 border border-blue-500 text-blue-200 px-4 py-3 rounded-lg mb-6">
<strong>Info:</strong> Dieser Account hat keinen Lizenzschlüssel und ist daher unbegrenzt.
</div>
{% endif %}
<!-- Quick Actions -->
<div class="card-bg border rounded-xl p-6 mb-8">
<h2 class="text-lg font-semibold text-white mb-4">Schnellzugriff</h2>
<div class="grid md:grid-cols-2 gap-4">
<a href="/company/accounts" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/>
</svg>
</div>
<div>
<p class="text-white font-medium">Mitarbeiter einladen</p>
<p class="text-gray-400 text-sm">Neue Teammitglieder hinzufügen</p>
</div>
</a>
<a href="/company/strategy" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</div>
<div>
<p class="text-white font-medium">Strategie bearbeiten</p>
<p class="text-gray-400 text-sm">Brand Voice und Richtlinien anpassen</p>
</div>
</a>
</div>
</div>
<!-- Recent Employees -->
{% if employees %}
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Mitarbeiter</h2>
<a href="/company/accounts" class="text-brand-highlight text-sm hover:underline">Alle anzeigen</a>
</div>
<div class="space-y-3">
{% for emp in employees[:5] %}
<div class="flex items-center gap-3 p-3 bg-brand-bg rounded-lg">
<div class="w-8 h-8 rounded-full bg-brand-highlight/20 flex items-center justify-center">
<span class="text-brand-highlight text-sm font-medium">{{ (emp.display_name or emp.email)[0] | upper }}</span>
</div>
<div class="flex-1">
<p class="text-white text-sm">{{ emp.display_name or emp.email }}</p>
<p class="text-gray-500 text-xs">{{ emp.email }}</p>
</div>
<span class="text-xs px-2 py-1 rounded {% if emp.onboarding_status == 'completed' %}bg-green-500/20 text-green-400{% else %}bg-yellow-500/20 text-yellow-400{% endif %}">
{% if emp.onboarding_status == 'completed' %}Aktiv{% else %}Onboarding{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,195 @@
{% extends "company_base.html" %}
{% block title %}Inhalte verwalten - {{ session.company_name }}{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto">
<h1 class="text-2xl font-bold text-white mb-6">Inhalte verwalten</h1>
<!-- Employee Selector -->
<div class="card-bg border rounded-xl p-6 mb-8">
<label class="block text-sm font-medium text-gray-300 mb-3">Mitarbeiter auswählen</label>
{% if active_employees %}
<div class="flex flex-wrap gap-3">
{% for emp in active_employees %}
<a href="/company/manage?employee_id={{ emp.id }}"
class="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors {% if selected_employee and selected_employee.id == emp.id %}bg-brand-highlight text-brand-bg-dark{% else %}bg-brand-bg hover:bg-brand-bg-light text-white{% endif %}">
<div class="w-8 h-8 rounded-full {% if selected_employee and selected_employee.id == emp.id %}bg-brand-bg-dark/20{% else %}bg-brand-highlight/20{% endif %} flex items-center justify-center">
{% if emp.linkedin_picture %}
<img src="{{ emp.linkedin_picture }}" alt="" class="w-8 h-8 rounded-full">
{% else %}
<span class="{% if selected_employee and selected_employee.id == emp.id %}text-brand-bg-dark{% else %}text-brand-highlight{% endif %} text-sm font-medium">{{ (emp.display_name or emp.email)[0] | upper }}</span>
{% endif %}
</div>
<span class="font-medium">{{ emp.display_name or emp.email }}</span>
</a>
{% 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 mb-4">Keine aktiven Mitarbeiter vorhanden</p>
<p class="text-gray-500 text-sm">Mitarbeiter erscheinen hier, sobald sie ihr Onboarding abgeschlossen haben.</p>
<a href="/company/accounts" class="inline-block mt-4 text-brand-highlight hover:underline">Mitarbeiter einladen</a>
</div>
{% endif %}
</div>
{% if selected_employee %}
<!-- Selected Employee Info -->
<div class="card-bg border rounded-xl p-6 mb-8">
<div class="flex items-center gap-4">
<div class="w-16 h-16 rounded-full bg-brand-highlight/20 flex items-center justify-center overflow-hidden">
{% if selected_employee.linkedin_picture %}
<img src="{{ selected_employee.linkedin_picture }}" alt="" class="w-16 h-16 rounded-full">
{% else %}
<span class="text-brand-highlight text-2xl font-medium">{{ (selected_employee.display_name or selected_employee.email)[0] | upper }}</span>
{% endif %}
</div>
<div>
<h2 class="text-xl font-bold text-white">{{ selected_employee.display_name or selected_employee.email }}</h2>
<p class="text-gray-400">{{ selected_employee.email }}</p>
</div>
</div>
</div>
<!-- Stats -->
<div class="grid md:grid-cols-3 gap-6 mb-8">
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div>
<p class="text-3xl font-bold text-white">{{ employee_posts | length }}</p>
<p class="text-gray-400 text-sm">Posts gesamt</p>
</div>
</div>
</div>
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-yellow-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-yellow-400" 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>
</div>
<div>
<p class="text-3xl font-bold text-white">{{ pending_posts }}</p>
<p class="text-gray-400 text-sm">Ausstehend</p>
</div>
</div>
</div>
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-400" 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>
</div>
<div>
<p class="text-3xl font-bold text-white">{{ approved_posts }}</p>
<p class="text-gray-400 text-sm">Genehmigt</p>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card-bg border rounded-xl p-6 mb-8">
<h3 class="text-lg font-semibold text-white mb-4">Aktionen</h3>
<div class="grid md:grid-cols-3 gap-4">
<a href="/company/manage/research?employee_id={{ selected_employee.id }}" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<div>
<p class="text-white font-medium">Research Topics</p>
<p class="text-gray-400 text-sm">Themen recherchieren</p>
</div>
</a>
<a href="/company/manage/create?employee_id={{ selected_employee.id }}" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="M12 4v16m8-8H4"/>
</svg>
</div>
<div>
<p class="text-white font-medium">Neuer Post</p>
<p class="text-gray-400 text-sm">KI-generiert erstellen</p>
</div>
</a>
<a href="/company/manage/posts?employee_id={{ selected_employee.id }}" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="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>
</div>
<div>
<p class="text-white font-medium">Alle Posts</p>
<p class="text-gray-400 text-sm">Posts anzeigen</p>
</div>
</a>
</div>
</div>
<!-- Recent Posts -->
{% if employee_posts %}
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">Letzte Posts</h3>
<a href="/company/manage/posts?employee_id={{ selected_employee.id }}" class="text-brand-highlight text-sm hover:underline">Alle anzeigen</a>
</div>
<div class="space-y-3">
{% for post in employee_posts[:5] %}
<a href="/company/manage/post/{{ post.id }}?employee_id={{ selected_employee.id }}" class="block p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="flex items-start gap-3">
<div class="flex-1">
<p class="text-white text-sm line-clamp-2">{{ post.post_content[:150] }}{% if post.post_content|length > 150 %}...{% endif %}</p>
<p class="text-gray-500 text-xs mt-2">{{ post.created_at.strftime('%d.%m.%Y %H:%M') }}</p>
</div>
<span class="text-xs px-2 py-1 rounded flex-shrink-0 {% if post.status == 'approved' %}bg-green-500/20 text-green-400{% elif post.status == 'rejected' %}bg-red-500/20 text-red-400{% else %}bg-yellow-500/20 text-yellow-400{% endif %}">
{% if post.status == 'approved' %}Genehmigt{% elif post.status == 'rejected' %}Abgelehnt{% else %}Ausstehend{% endif %}
</span>
</div>
</a>
{% endfor %}
</div>
</div>
{% else %}
<div class="card-bg border rounded-xl p-6 text-center">
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<p class="text-gray-400">Noch keine Posts vorhanden</p>
<a href="/company/manage/create?employee_id={{ selected_employee.id }}" class="inline-block mt-4 text-brand-highlight hover:underline">Ersten Post erstellen</a>
</div>
{% endif %}
{% else %}
<!-- No Employee Selected -->
{% if active_employees %}
<div class="card-bg border rounded-xl p-6 text-center">
<div class="w-16 h-16 bg-brand-highlight/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"/>
</svg>
</div>
<p class="text-white text-lg font-medium mb-2">Mitarbeiter auswählen</p>
<p class="text-gray-400">Wähle oben einen Mitarbeiter aus, um dessen Inhalte zu verwalten.</p>
</div>
{% endif %}
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,979 @@
{% extends "company_base.html" %}
{% block title %}Post erstellen - {{ employee_name }} - {{ session.company_name }}{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<div class="mb-6">
<nav class="flex items-center gap-2 text-sm">
<a href="/company/manage" class="text-gray-400 hover:text-white transition-colors">Inhalte verwalten</a>
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<a href="/company/manage?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">{{ employee_name }}</a>
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<span class="text-white">Post erstellen</span>
</nav>
</div>
<!-- Limit Warning -->
{% if limit_reached %}
<div class="max-w-2xl mx-auto mb-8">
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 flex-shrink-0" 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>
<strong class="font-semibold">Limit erreicht</strong>
<p class="text-sm mt-1">{{ limit_message }}</p>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Wizard Container (hidden during generation) -->
<div id="wizardContainer" {% if limit_reached %}style="pointer-events: none; opacity: 0.5;"{% endif %}>
<div class="max-w-2xl mx-auto">
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold text-white mb-2">Post erstellen</h1>
<p class="text-gray-400">Generiere einen neuen LinkedIn Post für {{ employee_name }}</p>
</div>
<!-- Wizard Steps Indicator -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div id="stepIndicator1" class="w-10 h-10 rounded-full bg-brand-highlight text-brand-bg-dark flex items-center justify-center font-bold">1</div>
<span class="ml-2 text-white font-medium hidden sm:inline">Post-Typ</span>
</div>
<div class="flex-1 h-1 mx-4 bg-brand-bg-light rounded">
<div id="progressLine1" class="h-full bg-brand-highlight rounded transition-all duration-300" style="width: 0%"></div>
</div>
<div class="flex items-center">
<div id="stepIndicator2" class="w-10 h-10 rounded-full bg-brand-bg-light text-gray-400 flex items-center justify-center font-bold">2</div>
<span class="ml-2 text-gray-400 font-medium hidden sm:inline">Thema</span>
</div>
<div class="flex-1 h-1 mx-4 bg-brand-bg-light rounded">
<div id="progressLine2" class="h-full bg-brand-highlight rounded transition-all duration-300" style="width: 0%"></div>
</div>
<div class="flex items-center">
<div id="stepIndicator3" class="w-10 h-10 rounded-full bg-brand-bg-light text-gray-400 flex items-center justify-center font-bold">3</div>
<span class="ml-2 text-gray-400 font-medium hidden sm:inline">Gedanken</span>
</div>
<div class="flex-1 h-1 mx-4 bg-brand-bg-light rounded">
<div id="progressLine3" class="h-full bg-brand-highlight rounded transition-all duration-300" style="width: 0%"></div>
</div>
<div class="flex items-center">
<div id="stepIndicator4" class="w-10 h-10 rounded-full bg-brand-bg-light text-gray-400 flex items-center justify-center font-bold">4</div>
<span class="ml-2 text-gray-400 font-medium hidden sm:inline">Hook</span>
</div>
</div>
</div>
<!-- Step 1: Post-Typ -->
<div id="step1" class="wizard-step card-bg rounded-xl border p-8">
<h2 class="text-xl font-semibold text-white mb-2">Post-Typ auswählen</h2>
<p class="text-gray-400 mb-6">Wähle einen Post-Typ, um die Topics zu filtern und den Stil anzupassen. <span class="text-gray-500">(optional)</span></p>
<div id="postTypeCards" class="flex flex-wrap gap-3 mb-8">
<p class="text-gray-500">Lade Post-Typen...</p>
</div>
<input type="hidden" id="selectedPostTypeId" value="">
<div class="flex justify-center">
<button onclick="goToStep(2)" class="px-8 py-3 rounded-lg font-medium bg-brand-bg hover:bg-brand-bg-light text-white transition-colors">
Überspringen
</button>
</div>
</div>
<!-- Step 2: Thema -->
<div id="step2" class="wizard-step card-bg rounded-xl border p-8 hidden">
<h2 class="text-xl font-semibold text-white mb-2">Thema auswählen</h2>
<p class="text-gray-400 mb-6">Wähle ein recherchiertes Topic oder gib ein eigenes ein.</p>
<div id="topicsList" class="space-y-2 max-h-72 overflow-y-auto mb-6">
<p class="text-gray-500">Lade Topics...</p>
</div>
<div class="border-t border-brand-bg-light pt-6 mt-6">
<label class="block text-sm font-medium text-gray-300 mb-3">Oder eigenes Topic eingeben</label>
<div class="space-y-3">
<input type="text" id="customTopicTitle" placeholder="Topic Titel" class="w-full input-bg border rounded-lg px-4 py-3 text-white">
<textarea id="customTopicFact" rows="3" placeholder="Fakt / Kernaussage zum Topic..." class="w-full input-bg border rounded-lg px-4 py-3 text-white"></textarea>
<input type="text" id="customTopicSource" placeholder="Quelle (optional)" class="w-full input-bg border rounded-lg px-4 py-3 text-white">
</div>
</div>
<div class="flex justify-between gap-3 mt-8">
<button onclick="goToStep(1)" class="px-6 py-3 rounded-lg font-medium bg-brand-bg hover:bg-brand-bg-light text-white transition-colors">
Zurück
</button>
<button onclick="validateAndGoToStep3()" class="btn-primary px-8 py-3 rounded-lg font-medium">
Weiter
</button>
</div>
</div>
<!-- Step 3: Gedanken -->
<div id="step3" class="wizard-step card-bg rounded-xl border p-8 hidden">
<h2 class="text-xl font-semibold text-white mb-2">Gedanken zum Thema</h2>
<p class="text-gray-400 mb-6">Was soll {{ employee_name }} zu diesem Thema sagen? Persönliche Meinung, Erfahrungen oder Einsichten.</p>
<div class="relative">
<textarea id="userThoughts" rows="8" placeholder="Schreibe die Gedanken ein...
z.B. 'Ich habe letzte Woche selbst erfahren, dass...'
oder 'Meine Meinung dazu ist...'"
class="w-full input-bg border rounded-lg px-4 py-4 text-white pr-16 text-lg"></textarea>
<!-- Speech-to-Text Button -->
<button id="speechBtn" type="button"
class="absolute right-4 top-4 p-3 rounded-lg bg-brand-bg hover:bg-brand-highlight hover:text-brand-bg-dark transition-colors group"
title="Spracheingabe starten">
<svg id="micIcon" class="w-6 h-6 text-gray-400 group-hover:text-brand-bg-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
</svg>
<svg id="micIconRecording" class="w-6 h-6 text-red-500 hidden animate-pulse" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"/>
</svg>
</button>
</div>
<div class="flex items-center justify-between mt-2">
<p id="speechSupport" class="text-xs text-gray-500"></p>
<p id="speechStatus" class="text-sm text-brand-highlight hidden"></p>
</div>
<div class="flex justify-between gap-3 mt-8">
<button onclick="goToStep(2)" class="px-6 py-3 rounded-lg font-medium bg-brand-bg hover:bg-brand-bg-light text-white transition-colors">
Zurück
</button>
<button onclick="goToStep(4)" class="btn-primary px-8 py-3 rounded-lg font-medium">
Weiter
</button>
</div>
</div>
<!-- Step 4: Hook-Auswahl -->
<div id="step4" class="wizard-step card-bg rounded-xl border p-8 hidden">
<h2 class="text-xl font-semibold text-white mb-2">Hook auswählen</h2>
<p class="text-gray-400 mb-6">Der Hook ist der erste Satz des Posts. Wähle einen KI-generierten oder schreibe einen eigenen.</p>
<div id="hookLoading" class="text-center py-12">
<svg class="w-10 h-10 animate-spin mx-auto text-brand-highlight" 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>
<p class="text-gray-400 mt-4">Generiere Hooks...</p>
</div>
<div id="hookOptions" class="space-y-3 hidden">
<!-- Hook options will be inserted here -->
</div>
<div id="hookError" class="text-red-400 text-center py-6 hidden"></div>
<div class="border-t border-brand-bg-light pt-6 mt-6">
<label class="block text-sm font-medium text-gray-300 mb-3">Oder eigenen Hook eingeben</label>
<textarea id="customHook" rows="2" placeholder="Eigener Hook-Text..."
class="w-full input-bg border rounded-lg px-4 py-3 text-white"></textarea>
</div>
<div class="flex justify-between gap-3 mt-8">
<button onclick="goToStep(3)" class="px-6 py-3 rounded-lg font-medium bg-brand-bg hover:bg-brand-bg-light text-white transition-colors">
Zurück
</button>
<button onclick="startPostCreation()" id="createPostBtn" class="btn-primary px-8 py-3 rounded-lg font-medium flex items-center gap-2">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
Post erstellen
</button>
</div>
</div>
</div>
</div>
<!-- Generation & Result Container (shown after clicking "Post erstellen") -->
<div id="generationContainer" class="hidden">
<div class="max-w-4xl mx-auto">
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold text-white mb-2">Post wird generiert</h1>
<p class="text-gray-400" id="topicTitle"></p>
</div>
<!-- Progress Section -->
<div id="generationProgress" class="card-bg rounded-xl border p-8 mb-8">
<div class="max-w-xl mx-auto">
<div class="flex items-center justify-between mb-3">
<span id="progressMessage" class="text-gray-300">Starte Post-Erstellung...</span>
<span id="progressPercent" class="text-gray-400 font-medium">0%</span>
</div>
<div class="w-full bg-brand-bg-dark rounded-full h-3">
<div id="progressBar" class="bg-brand-highlight h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div id="iterationInfo" class="mt-3 text-sm text-gray-400 text-center"></div>
</div>
</div>
<!-- Live Versions Display -->
<div id="liveVersions" class="hidden mb-8">
<div class="card-bg rounded-xl border p-8">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-white">Live-Vorschau</h3>
<div id="versionTabs" class="flex gap-2"></div>
</div>
<div id="versionsContainer"></div>
</div>
</div>
<!-- Final Result -->
<div id="finalResult" class="hidden">
<div class="card-bg rounded-xl border p-8">
<div id="resultHeader" class="flex items-center justify-between mb-6"></div>
<div id="postResult" class="bg-brand-bg/50 rounded-lg p-6 mb-6"></div>
<!-- Image Upload Area -->
<div id="resultImageArea" class="mb-6 hidden">
<div id="resultImageUploadZone" class="border-2 border-dashed border-brand-bg-light rounded-xl p-6 text-center cursor-pointer hover:border-brand-highlight transition-colors">
<input type="file" id="resultImageInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<p class="text-sm text-gray-400">Bild hierher ziehen oder <span class="text-brand-highlight">durchsuchen</span></p>
<p class="text-xs text-gray-500 mt-1">JPEG, PNG, GIF, WebP - max. 5 MB</p>
</div>
<div id="resultImageProgress" class="hidden mt-2">
<div style="height:4px;background:rgba(61,72,72,0.8);border-radius:2px;overflow:hidden;">
<div id="resultImageProgressBar" style="height:100%;background:#ffc700;border-radius:2px;transition:width 0.3s;width:0%"></div>
</div>
<p class="text-xs text-gray-400 mt-1 text-center">Wird hochgeladen...</p>
</div>
<div id="resultImagePreview" class="hidden mt-3">
<img id="resultImageImg" src="" alt="Post-Bild" class="rounded-lg w-full max-h-64 object-cover mb-2">
<div class="flex gap-2 justify-center">
<button onclick="document.getElementById('resultImageReplaceInput').click()" class="px-3 py-1.5 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg text-sm transition-colors">Ersetzen</button>
<button onclick="removeResultImage()" id="removeResultImageBtn" class="px-3 py-1.5 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm transition-colors">Entfernen</button>
</div>
<input type="file" id="resultImageReplaceInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
</div>
</div>
<div id="resultActions" class="flex gap-3 flex-wrap justify-center"></div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ==================== CONFIG ====================
const USER_ID = '{{ user_id }}';
const EMPLOYEE_ID = '{{ employee_id }}';
const EMPLOYEE_NAME = '{{ employee_name }}';
// ==================== STATE ====================
let currentStep = 1;
let selectedTopic = null;
let currentPostTypes = [];
let currentTopics = [];
let selectedHook = null;
let generatedHooks = [];
let isRecording = false;
let currentVersionIndex = 0;
// ==================== ELEMENTS ====================
const steps = [1, 2, 3, 4];
const wizardContainer = document.getElementById('wizardContainer');
const generationContainer = document.getElementById('generationContainer');
const selectedPostTypeIdInput = document.getElementById('selectedPostTypeId');
const postTypeCards = document.getElementById('postTypeCards');
const topicsList = document.getElementById('topicsList');
const userThoughtsInput = document.getElementById('userThoughts');
const speechBtn = document.getElementById('speechBtn');
const micIcon = document.getElementById('micIcon');
const micIconRecording = document.getElementById('micIconRecording');
const speechStatus = document.getElementById('speechStatus');
const speechSupport = document.getElementById('speechSupport');
const hookLoading = document.getElementById('hookLoading');
const hookOptions = document.getElementById('hookOptions');
const hookError = document.getElementById('hookError');
const customHook = document.getElementById('customHook');
const generationProgress = document.getElementById('generationProgress');
const progressBar = document.getElementById('progressBar');
const progressMessage = document.getElementById('progressMessage');
const progressPercent = document.getElementById('progressPercent');
const iterationInfo = document.getElementById('iterationInfo');
const liveVersions = document.getElementById('liveVersions');
const versionTabs = document.getElementById('versionTabs');
const versionsContainer = document.getElementById('versionsContainer');
const finalResult = document.getElementById('finalResult');
const resultHeader = document.getElementById('resultHeader');
const postResult = document.getElementById('postResult');
const resultActions = document.getElementById('resultActions');
const topicTitleEl = document.getElementById('topicTitle');
// ==================== WIZARD NAVIGATION ====================
function goToStep(step) {
document.querySelectorAll('.wizard-step').forEach(el => el.classList.add('hidden'));
document.getElementById(`step${step}`).classList.remove('hidden');
steps.forEach(s => {
const indicator = document.getElementById(`stepIndicator${s}`);
const progressLine = document.getElementById(`progressLine${s}`);
if (s < step) {
indicator.className = 'w-10 h-10 rounded-full bg-brand-highlight text-brand-bg-dark flex items-center justify-center font-bold';
if (progressLine) progressLine.style.width = '100%';
} else if (s === step) {
indicator.className = 'w-10 h-10 rounded-full bg-brand-highlight text-brand-bg-dark flex items-center justify-center font-bold';
if (progressLine) progressLine.style.width = '0%';
} else {
indicator.className = 'w-10 h-10 rounded-full bg-brand-bg-light text-gray-400 flex items-center justify-center font-bold';
if (progressLine) progressLine.style.width = '0%';
}
});
currentStep = step;
if (step === 4) loadHooks();
}
function validateAndGoToStep3() {
const customTitle = document.getElementById('customTopicTitle').value.trim();
const customFact = document.getElementById('customTopicFact').value.trim();
if (!selectedTopic && (!customTitle || !customFact)) {
alert('Bitte wähle ein Topic aus oder gib ein eigenes ein (Titel und Fakt sind erforderlich).');
return;
}
if (customTitle && customFact) {
selectedTopic = {
title: customTitle,
fact: customFact,
source: document.getElementById('customTopicSource').value.trim() || null,
category: 'Custom'
};
}
goToStep(3);
}
// ==================== LOAD DATA ====================
async function loadData() {
try {
const ptResponse = await fetch(`/api/post-types?user_id=${USER_ID}`);
const ptData = await ptResponse.json();
if (ptData.post_types && ptData.post_types.length > 0) {
currentPostTypes = ptData.post_types;
postTypeCards.innerHTML = `
<button type="button" onclick="selectPostType('')" id="ptc_all"
class="px-5 py-3 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white font-medium">
Alle Typen
</button>
` + ptData.post_types.map(pt => `
<button type="button" onclick="selectPostType('${pt.id}')" id="ptc_${pt.id}"
class="px-5 py-3 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
${pt.name}
${pt.has_analysis ? '<span class="ml-1 text-green-400 text-xs">*</span>' : ''}
</button>
`).join('');
} else {
postTypeCards.innerHTML = '<p class="text-gray-500">Keine Post-Typen verfügbar. Du kannst diesen Schritt überspringen.</p>';
}
} catch (error) {
console.error('Failed to load post types:', error);
postTypeCards.innerHTML = '<p class="text-gray-500">Keine Post-Typen verfügbar. Du kannst diesen Schritt überspringen.</p>';
}
loadTopics();
}
async function loadTopics(postTypeId = null) {
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
try {
let url = `/api/topics?user_id=${USER_ID}`;
if (postTypeId) url += `&post_type_id=${postTypeId}`;
const response = await fetch(url);
const data = await response.json();
if (data.topics && data.topics.length > 0) {
renderTopicsList(data);
} else {
let message = '';
if (data.used_count > 0) {
message = `<div class="text-center py-6">
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
<a href="/company/manage/research?employee_id=${EMPLOYEE_ID}" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
</div>`;
} else {
message = `<div class="text-center py-6">
<p class="text-gray-400 mb-2">Keine Topics gefunden${postTypeId ? ' für diesen Post-Typ' : ''}.</p>
<a href="/company/manage/research?employee_id=${EMPLOYEE_ID}" class="text-brand-highlight hover:underline">Recherche starten</a>
</div>`;
}
topicsList.innerHTML = message;
}
} catch (error) {
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
}
}
function selectPostType(typeId) {
selectedPostTypeIdInput.value = typeId;
document.querySelectorAll('[id^="ptc_"]').forEach(card => {
if (card.id === `ptc_${typeId}` || (typeId === '' && card.id === 'ptc_all')) {
card.className = 'px-5 py-3 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white font-medium';
} else {
card.className = 'px-5 py-3 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
}
});
loadTopics(typeId);
goToStep(2);
}
function renderTopicsList(data) {
currentTopics = data.topics;
selectedTopic = null;
let statsHtml = '';
if (data.used_count > 0) {
statsHtml = `<p class="text-xs text-gray-500 mb-3">${data.available_count} verfügbar · ${data.used_count} bereits verwendet</p>`;
}
topicsList.innerHTML = statsHtml + data.topics.map((topic, i) => `
<label class="flex items-start gap-3 p-4 bg-brand-bg/50 rounded-lg cursor-pointer hover:bg-brand-bg transition-colors border border-transparent hover:border-brand-highlight/30">
<input type="radio" name="topic" value="${i}" class="mt-1 text-brand-highlight" data-topic-index="${i}">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">${escapeHtml(topic.category || 'Topic')}</span>
</div>
<p class="font-medium text-white">${escapeHtml(topic.title)}</p>
${topic.angle ? `<p class="text-xs text-brand-highlight/80 mt-1">→ ${escapeHtml(topic.angle)}</p>` : ''}
${topic.fact ? `<p class="text-sm text-gray-400 mt-1">${escapeHtml(topic.fact.substring(0, 120))}${topic.fact.length > 120 ? '...' : ''}</p>` : ''}
</div>
</label>
`).join('');
document.querySelectorAll('input[name="topic"]').forEach(radio => {
radio.addEventListener('change', () => {
const index = parseInt(radio.dataset.topicIndex, 10);
selectedTopic = currentTopics[index];
document.getElementById('customTopicTitle').value = '';
document.getElementById('customTopicFact').value = '';
document.getElementById('customTopicSource').value = '';
});
});
}
['customTopicTitle', 'customTopicFact', 'customTopicSource'].forEach(id => {
document.getElementById(id).addEventListener('input', () => {
selectedTopic = null;
document.querySelectorAll('input[name="topic"]').forEach(radio => radio.checked = false);
});
});
// ==================== SPEECH TO TEXT ====================
let mediaRecorder = null;
let audioChunks = [];
function initSpeechRecognition() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.';
speechBtn.classList.add('opacity-50', 'cursor-not-allowed');
speechBtn.disabled = true;
return;
}
speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.';
speechBtn.onclick = async () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
};
}
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioChunks = [];
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
mediaRecorder = new MediaRecorder(stream, { mimeType });
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) audioChunks.push(event.data);
};
mediaRecorder.onstop = async () => {
stream.getTracks().forEach(track => track.stop());
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
speechStatus.textContent = 'Transkribiere...';
try {
const text = await transcribeAudio(audioBlob);
if (text && text.trim()) {
if (userThoughtsInput.value && !userThoughtsInput.value.endsWith(' ')) {
userThoughtsInput.value += ' ';
}
userThoughtsInput.value += text.trim();
}
speechStatus.classList.add('hidden');
} catch (error) {
speechStatus.textContent = `Fehler: ${error.message}`;
setTimeout(() => speechStatus.classList.add('hidden'), 3000);
}
};
mediaRecorder.start();
isRecording = true;
micIcon.classList.add('hidden');
micIconRecording.classList.remove('hidden');
speechStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse mr-2"></span>Aufnahme läuft...';
speechStatus.classList.remove('hidden');
speechBtn.classList.add('bg-red-500/20');
} catch (error) {
console.error('Failed to start recording:', error);
speechStatus.textContent = 'Mikrofon-Zugriff verweigert.';
speechStatus.classList.remove('hidden');
setTimeout(() => speechStatus.classList.add('hidden'), 3000);
}
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
isRecording = false;
micIcon.classList.remove('hidden');
micIconRecording.classList.add('hidden');
speechBtn.classList.remove('bg-red-500/20');
}
async function transcribeAudio(audioBlob) {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
const response = await fetch('/api/transcribe', { method: 'POST', body: formData });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Transkription fehlgeschlagen');
}
const data = await response.json();
return data.text;
}
// ==================== HOOK GENERATION ====================
async function loadHooks() {
hookLoading.classList.remove('hidden');
hookOptions.classList.add('hidden');
hookError.classList.add('hidden');
generatedHooks = [];
selectedHook = null;
const formData = new FormData();
formData.append('topic_json', JSON.stringify(selectedTopic));
formData.append('user_thoughts', userThoughtsInput.value.trim());
formData.append('user_id', USER_ID);
if (selectedPostTypeIdInput.value) {
formData.append('post_type_id', selectedPostTypeIdInput.value);
}
try {
const response = await fetch('/api/hooks', { method: 'POST', body: formData });
if (!response.ok) throw new Error('Hook-Generierung fehlgeschlagen');
const data = await response.json();
generatedHooks = data.hooks || [];
if (generatedHooks.length === 0) throw new Error('Keine Hooks generiert');
renderHookOptions();
hookLoading.classList.add('hidden');
hookOptions.classList.remove('hidden');
} catch (error) {
console.error('Hook generation failed:', error);
hookLoading.classList.add('hidden');
hookError.textContent = `Fehler: ${error.message}. Du kannst trotzdem einen eigenen Hook eingeben.`;
hookError.classList.remove('hidden');
}
}
function renderHookOptions() {
hookOptions.innerHTML = generatedHooks.map((hook, i) => `
<label class="flex items-start gap-3 p-4 bg-brand-bg/50 rounded-lg cursor-pointer hover:bg-brand-bg transition-colors border-2 ${selectedHook === hook.hook ? 'border-brand-highlight bg-brand-highlight/10' : 'border-transparent hover:border-brand-highlight/30'}">
<input type="radio" name="hook" value="${i}" class="mt-1 text-brand-highlight" ${selectedHook === hook.hook ? 'checked' : ''}>
<div class="flex-1">
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-brand-bg-light text-gray-300 rounded mb-2">${escapeHtml(hook.style)}</span>
<p class="text-white">${escapeHtml(hook.hook)}</p>
</div>
</label>
`).join('');
document.querySelectorAll('input[name="hook"]').forEach(radio => {
radio.addEventListener('change', () => {
const index = parseInt(radio.value, 10);
selectedHook = generatedHooks[index].hook;
customHook.value = '';
renderHookOptions();
});
});
}
customHook.addEventListener('input', () => {
if (customHook.value.trim()) {
selectedHook = null;
document.querySelectorAll('input[name="hook"]').forEach(radio => radio.checked = false);
}
});
// ==================== POST CREATION ====================
async function startPostCreation() {
const finalHook = customHook.value.trim() || selectedHook || '';
wizardContainer.classList.add('hidden');
generationContainer.classList.remove('hidden');
topicTitleEl.textContent = selectedTopic?.title || 'Benutzerdefiniertes Topic';
generationProgress.classList.remove('hidden');
liveVersions.classList.add('hidden');
finalResult.classList.add('hidden');
currentVersionIndex = 0;
const formData = new FormData();
formData.append('topic_json', JSON.stringify(selectedTopic));
formData.append('user_thoughts', userThoughtsInput.value.trim());
formData.append('selected_hook', finalHook);
formData.append('user_id', USER_ID);
if (selectedPostTypeIdInput.value) {
formData.append('post_type_id', selectedPostTypeIdInput.value);
}
try {
const response = await fetch('/api/posts', { method: 'POST', body: formData });
const data = await response.json();
const taskId = data.task_id;
window.lastProgressData = null;
const pollInterval = setInterval(async () => {
const statusResponse = await fetch(`/api/tasks/${taskId}`);
const status = await statusResponse.json();
progressBar.style.width = `${status.progress}%`;
progressPercent.textContent = `${status.progress}%`;
progressMessage.textContent = status.message;
if (status.iteration !== undefined) {
iterationInfo.textContent = `Iteration ${status.iteration} von ${status.max_iterations}`;
}
if (status.versions && status.versions.length > 0) {
window.lastProgressData = status;
if (status.versions.length > currentVersionIndex + 1) {
currentVersionIndex = status.versions.length - 1;
}
renderVersions(status.versions, status.feedback_list || []);
}
if (status.status === 'completed') {
clearInterval(pollInterval);
showFinalResult(status.result);
} else if (status.status === 'error') {
clearInterval(pollInterval);
showError(status.message);
}
}, 1000);
} catch (error) {
showError(error.message);
}
}
function showFinalResult(result) {
generationProgress.classList.add('hidden');
liveVersions.classList.add('hidden');
finalResult.classList.remove('hidden');
resultHeader.innerHTML = `
<div class="flex items-center gap-3 flex-wrap">
<span class="px-3 py-1.5 rounded-lg text-sm font-medium ${result.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
${result.approved ? 'Approved' : 'Review needed'}
</span>
<span class="text-gray-400">Score: <span class="text-white font-medium">${result.final_score}/100</span></span>
<span class="text-gray-400">Iterationen: <span class="text-white">${result.iterations}</span></span>
</div>
`;
postResult.innerHTML = `<pre class="whitespace-pre-wrap text-gray-200 font-sans text-lg leading-relaxed">${result.final_post}</pre>`;
// Store post ID for image upload and show image area
window.currentResultPostId = result.post_id;
const imageArea = document.getElementById('resultImageArea');
imageArea.classList.remove('hidden');
document.getElementById('resultImageUploadZone').classList.remove('hidden');
document.getElementById('resultImagePreview').classList.add('hidden');
document.getElementById('resultImageProgress').classList.add('hidden');
initResultImageUpload();
resultActions.innerHTML = `
<button onclick="copyPost()" class="px-6 py-3 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-white transition-colors flex items-center gap-2">
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
Kopieren
</button>
<a href="/company/manage/post/${result.post_id}?employee_id=${EMPLOYEE_ID}" class="px-6 py-3 bg-brand-highlight hover:bg-brand-highlight/90 rounded-lg text-brand-bg-dark font-medium transition-colors flex items-center gap-2">
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
Post öffnen
</a>
<button onclick="resetWizard()" class="px-6 py-3 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-white transition-colors flex items-center gap-2">
<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>
Neuen Post erstellen
</button>
`;
}
function showError(message) {
generationProgress.classList.add('hidden');
finalResult.classList.remove('hidden');
resultHeader.innerHTML = `<span class="text-red-400 font-medium">Fehler bei der Generierung</span>`;
postResult.innerHTML = `<p class="text-red-400">${message}</p>`;
resultActions.innerHTML = `
<button onclick="resetWizard()" class="px-6 py-3 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-white transition-colors">
Zurück zum Wizard
</button>
`;
}
function resetWizard() {
currentStep = 1;
selectedTopic = null;
selectedHook = null;
generatedHooks = [];
currentVersionIndex = 0;
document.getElementById('customTopicTitle').value = '';
document.getElementById('customTopicFact').value = '';
document.getElementById('customTopicSource').value = '';
userThoughtsInput.value = '';
customHook.value = '';
document.querySelectorAll('input[name="topic"]').forEach(radio => radio.checked = false);
document.querySelectorAll('input[name="hook"]').forEach(radio => radio.checked = false);
generationContainer.classList.add('hidden');
wizardContainer.classList.remove('hidden');
goToStep(1);
}
// ==================== VERSION DISPLAY ====================
function renderVersions(versions, feedbackList) {
if (!versions || versions.length === 0) {
liveVersions.classList.add('hidden');
return;
}
liveVersions.classList.remove('hidden');
versionTabs.innerHTML = versions.map((_, i) => `
<button onclick="showVersion(${i})" id="versionTab${i}"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
${i === currentVersionIndex ? 'bg-brand-highlight text-brand-bg-dark' : 'bg-brand-bg text-gray-300 hover:bg-brand-bg-light'}">
V${i + 1}
${feedbackList[i] ? `<span class="ml-1 opacity-75">(${feedbackList[i].overall_score || '?'})</span>` : ''}
</button>
`).join('');
const currentVersion = versions[currentVersionIndex];
const currentFeedback = feedbackList[currentVersionIndex];
versionsContainer.innerHTML = `
<div class="grid grid-cols-1 ${currentFeedback ? 'lg:grid-cols-2' : ''} gap-6">
<div class="bg-brand-bg/50 rounded-lg p-5">
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-medium text-gray-300">Version ${currentVersionIndex + 1}</span>
${currentFeedback ? `
<span class="px-2 py-0.5 text-xs rounded ${currentFeedback.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
${currentFeedback.approved ? 'Approved' : `Score: ${currentFeedback.overall_score}/100`}
</span>
` : '<span class="text-xs text-gray-500">Wird bewertet...</span>'}
</div>
<pre class="whitespace-pre-wrap text-gray-200 font-sans text-sm max-h-80 overflow-y-auto">${currentVersion}</pre>
</div>
${currentFeedback ? `
<div class="bg-brand-bg/30 rounded-lg p-5 border border-brand-bg-light">
<span class="text-sm font-medium text-gray-300 block mb-3">Kritik</span>
<p class="text-sm text-gray-400 mb-4">${currentFeedback.feedback || 'Keine Kritik'}</p>
${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? `
<div>
<span class="text-xs font-medium text-gray-400">Verbesserungen:</span>
<ul class="mt-2 space-y-1">
${currentFeedback.improvements.map(imp => `
<li class="text-xs text-gray-500 flex items-start gap-2">
<span class="text-yellow-500 mt-0.5">•</span> ${imp}
</li>
`).join('')}
</ul>
</div>
` : ''}
</div>
` : ''}
</div>
`;
}
function showVersion(index) {
currentVersionIndex = index;
const cachedData = window.lastProgressData;
if (cachedData) renderVersions(cachedData.versions, cachedData.feedback_list);
}
function copyPost() {
const postText = document.querySelector('#postResult pre').textContent;
navigator.clipboard.writeText(postText).then(() => {
const btn = document.querySelector('[onclick="copyPost()"]');
const originalText = btn.innerHTML;
btn.innerHTML = '<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="M5 13l4 4L19 7"/></svg> Kopiert!';
setTimeout(() => { btn.innerHTML = originalText; }, 2000);
});
}
// ==================== HELPERS ====================
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg z-50 transition-all transform translate-y-0 opacity-100 ${
type === 'success' ? 'bg-green-600 text-white' :
type === 'error' ? 'bg-red-600 text-white' :
'bg-brand-bg-light text-white'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-2');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// ==================== RESULT IMAGE UPLOAD ====================
let resultImageInitialized = false;
async function handleResultImageUpload(file) {
if (!file || !window.currentResultPostId) return;
const uploadZone = document.getElementById('resultImageUploadZone');
const progressEl = document.getElementById('resultImageProgress');
const progressBar = document.getElementById('resultImageProgressBar');
const previewEl = document.getElementById('resultImagePreview');
uploadZone.classList.add('hidden');
progressEl.classList.remove('hidden');
progressBar.style.width = '30%';
try {
const formData = new FormData();
formData.append('image', file);
progressBar.style.width = '60%';
const response = await fetch(`/api/posts/${window.currentResultPostId}/image`, {
method: 'POST',
body: formData
});
progressBar.style.width = '90%';
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Upload fehlgeschlagen');
}
const result = await response.json();
progressBar.style.width = '100%';
document.getElementById('resultImageImg').src = result.image_url;
setTimeout(() => {
progressEl.classList.add('hidden');
previewEl.classList.remove('hidden');
}, 300);
showToast('Bild erfolgreich hochgeladen!', 'success');
} catch (error) {
console.error('Image upload error:', error);
showToast('Fehler: ' + error.message, 'error');
progressEl.classList.add('hidden');
uploadZone.classList.remove('hidden');
}
}
async function removeResultImage() {
if (!window.currentResultPostId) return;
const btn = document.getElementById('removeResultImageBtn');
const originalHTML = btn.innerHTML;
btn.innerHTML = 'Wird entfernt...';
btn.disabled = true;
try {
const response = await fetch(`/api/posts/${window.currentResultPostId}/image`, { method: 'DELETE' });
if (!response.ok) throw new Error('Löschen fehlgeschlagen');
document.getElementById('resultImagePreview').classList.add('hidden');
document.getElementById('resultImageUploadZone').classList.remove('hidden');
showToast('Bild entfernt.', 'success');
} catch (error) {
console.error('Image delete error:', error);
showToast('Fehler: ' + error.message, 'error');
} finally {
btn.innerHTML = originalHTML;
btn.disabled = false;
}
}
function initResultImageUpload() {
if (resultImageInitialized) return;
resultImageInitialized = true;
const uploadZone = document.getElementById('resultImageUploadZone');
const fileInput = document.getElementById('resultImageInput');
const replaceInput = document.getElementById('resultImageReplaceInput');
if (!uploadZone) return;
uploadZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => { if (e.target.files[0]) handleResultImageUpload(e.target.files[0]); });
if (replaceInput) {
replaceInput.addEventListener('change', (e) => { if (e.target.files[0]) handleResultImageUpload(e.target.files[0]); });
}
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('border-brand-highlight', 'bg-brand-highlight/5'); });
uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('border-brand-highlight', 'bg-brand-highlight/5'); });
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('border-brand-highlight', 'bg-brand-highlight/5');
if (e.dataTransfer.files[0]) handleResultImageUpload(e.dataTransfer.files[0]);
});
}
// ==================== INIT ====================
document.addEventListener('DOMContentLoaded', () => {
loadData();
initSpeechRecognition();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,393 @@
{% extends "company_base.html" %}
{% block title %}{{ post.topic_title }} - {{ employee_name }} - {{ session.company_name }}{% endblock %}
{% block head %}
<style>
.section-card {
background: rgba(61, 72, 72, 0.3);
border: 1px solid rgba(61, 72, 72, 0.6);
}
.linkedin-preview {
background: #ffffff;
border-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
color: rgba(0, 0, 0, 0.9);
overflow: hidden;
}
.linkedin-header {
padding: 12px 16px;
display: flex;
align-items: flex-start;
gap: 8px;
}
.linkedin-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #0a66c2 0%, #004182 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 18px;
flex-shrink: 0;
}
.linkedin-user-info { flex: 1; min-width: 0; }
.linkedin-name { font-weight: 600; font-size: 14px; color: rgba(0, 0, 0, 0.9); }
.linkedin-headline { font-size: 12px; color: rgba(0, 0, 0, 0.6); margin-top: 2px; }
.linkedin-timestamp { font-size: 12px; color: rgba(0, 0, 0, 0.6); display: flex; align-items: center; gap: 4px; margin-top: 2px; }
.linkedin-content { padding: 0 16px 12px; font-size: 14px; line-height: 1.5; color: rgba(0, 0, 0, 0.9); white-space: pre-wrap; }
.linkedin-engagement { padding: 8px 16px; border-top: 1px solid rgba(0, 0, 0, 0.08); display: flex; align-items: center; gap: 4px; font-size: 12px; color: rgba(0, 0, 0, 0.6); }
.linkedin-actions { display: flex; border-top: 1px solid rgba(0, 0, 0, 0.08); }
.linkedin-action-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 12px 8px; font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.6); }
.linkedin-action-btn svg { width: 20px; height: 20px; }
.linkedin-post-image { width: 100%; max-height: 400px; object-fit: cover; }
.image-upload-zone { border: 2px dashed rgba(61, 72, 72, 0.8); border-radius: 0.75rem; padding: 1.5rem; text-align: center; cursor: pointer; transition: all 0.2s; }
.image-upload-zone:hover, .image-upload-zone.dragover { border-color: #ffc700; background: rgba(255, 199, 0, 0.05); }
.image-upload-zone input[type="file"] { display: none; }
.image-preview-container { position: relative; border-radius: 0.75rem; overflow: hidden; }
.image-preview-container img { width: 100%; border-radius: 0.75rem; }
.image-upload-progress { height: 4px; background: rgba(61, 72, 72, 0.8); border-radius: 2px; overflow: hidden; margin-top: 0.5rem; }
.image-upload-progress-bar { height: 100%; background: #ffc700; border-radius: 2px; transition: width 0.3s; }
.loading-spinner { border: 2px solid rgba(255, 199, 0, 0.2); border-top-color: #ffc700; border-radius: 50%; width: 20px; height: 20px; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<div class="mb-6">
<nav class="flex items-center gap-2 text-sm">
<a href="/company/manage" class="text-gray-400 hover:text-white transition-colors">Inhalte verwalten</a>
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<a href="/company/manage?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">{{ employee_name }}</a>
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<a href="/company/manage/posts?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">Posts</a>
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<span class="text-white truncate max-w-xs">{{ post.topic_title or 'Post' }}</span>
</nav>
</div>
<!-- Header -->
<div class="mb-6">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<h1 class="text-2xl font-bold text-white mb-2">{{ post.topic_title or 'Untitled Post' }}</h1>
<div class="flex items-center gap-3 text-sm text-gray-400 flex-wrap">
<span>{{ post.created_at.strftime('%d.%m.%Y um %H:%M Uhr') if post.created_at else 'N/A' }}</span>
<span class="text-gray-600">|</span>
<span>{{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }}</span>
<span class="text-gray-600">|</span>
<span>{{ employee_name }}</span>
</div>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span class="px-3 py-1.5 rounded-lg text-sm font-medium {{ 'bg-green-600/30 text-green-300 border border-green-600/50' if post.status == 'approved' else 'bg-yellow-600/30 text-yellow-300 border border-yellow-600/50' if post.status == 'draft' else 'bg-blue-600/30 text-blue-300 border border-blue-600/50' }}">
{% if post.status == 'draft' %}Vorschlag{% elif post.status == 'approved' %}Bearbeitet{% elif post.status == 'published' %}Veröffentlicht{% else %}{{ post.status | capitalize }}{% endif %}
</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Post Content -->
<div class="xl:col-span-2">
<div class="section-card rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
LinkedIn Post
</h2>
<button onclick="copyToClipboard()" class="px-3 py-1.5 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-gray-300 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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
Kopieren
</button>
</div>
<!-- LinkedIn Preview -->
<div class="linkedin-preview shadow-lg">
<div class="linkedin-header">
<div class="linkedin-avatar">{{ employee_name[:2] | upper if employee_name else 'UN' }}</div>
<div class="linkedin-user-info">
<div class="linkedin-name">{{ employee_name }}</div>
<div class="linkedin-headline">{{ session.company_name }}</div>
<div class="linkedin-timestamp">
<span>{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'Jetzt' }}</span>
<span></span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1a7 7 0 107 7 7 7 0 00-7-7zM3 8a5 5 0 011-3l.55.55A1.5 1.5 0 015 6.62v1.07a.75.75 0 00.22.53l.56.56a.75.75 0 00.53.22H7v.69a.75.75 0 00.22.53l.56.56a.75.75 0 01.22.53V13a5 5 0 01-5-5zm6.24 4.83l2-2.46a.75.75 0 00.09-.8l-.58-1.16A.76.76 0 0010 8H7v-.19a.51.51 0 01.28-.45l.38-.19a.74.74 0 00.3-1L7.4 5.19a.75.75 0 00-.67-.41H5.67a.75.75 0 01-.44-.14l-.34-.26a5 5 0 017.35 8.44z"/>
</svg>
</div>
</div>
</div>
<div class="linkedin-content">{{ post.post_content }}</div>
{% if post.image_url %}
<img id="linkedinPostImage" src="{{ post.image_url }}" alt="Post image" class="linkedin-post-image">
{% else %}
<img id="linkedinPostImage" src="" alt="Post image" class="linkedin-post-image" style="display: none;">
{% endif %}
<div class="linkedin-engagement">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#0a66c2">
<path d="M19.46 11l-3.91-3.91a7 7 0 01-1.69-2.74l-.49-1.47A2.76 2.76 0 0010.76 1 2.75 2.75 0 008 3.74v1.12a9.19 9.19 0 00.46 2.85L8.89 9H4.12A2.12 2.12 0 002 11.12a2.16 2.16 0 00.92 1.76A2.11 2.11 0 002 14.62a2.14 2.14 0 001.28 2 2 2 0 00-.28 1 2.12 2.12 0 002 2.12v.14A2.12 2.12 0 007.12 22h7.49a8.08 8.08 0 003.58-.84l.31-.16H21V11z"/>
</svg>
<span style="margin-left: 4px;">42</span>
<span style="margin-left: auto;">12 Kommentare • 3 Reposts</span>
</div>
<div class="linkedin-actions">
<button class="linkedin-action-btn">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.46 11l-3.91-3.91a7 7 0 01-1.69-2.74l-.49-1.47A2.76 2.76 0 0010.76 1 2.75 2.75 0 008 3.74v1.12a9.19 9.19 0 00.46 2.85L8.89 9H4.12A2.12 2.12 0 002 11.12a2.16 2.16 0 00.92 1.76A2.11 2.11 0 002 14.62a2.14 2.14 0 001.28 2 2 2 0 00-.28 1 2.12 2.12 0 002 2.12v.14A2.12 2.12 0 007.12 22h7.49a8.08 8.08 0 003.58-.84l.31-.16H21V11z"/></svg>
Gefällt mir
</button>
<button class="linkedin-action-btn">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 9h10v1H7zm0 4h7v-1H7zm16-2a6.78 6.78 0 01-2.84 5.61L12 22v-4H8A7 7 0 018 4h8a7 7 0 017 7z"/></svg>
Kommentieren
</button>
<button class="linkedin-action-btn">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13.96 5H6c-.55 0-1 .45-1 1v11H3V6c0-1.66 1.34-3 3-3h7.96L12 0l1.96 5zM17 7h-7c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h7c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2z"/></svg>
Reposten
</button>
<button class="linkedin-action-btn">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3L0 10l7.66 4.26L16 8l-6.26 8.34L14 24l7-21z"/></svg>
Senden
</button>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Actions -->
<div class="section-card rounded-xl p-6">
<h3 class="font-semibold text-white mb-4">Aktionen</h3>
<div class="space-y-3">
<button onclick="updateStatus('approved')" class="w-full px-4 py-3 bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 rounded-lg transition-colors flex items-center justify-center gap-2 {% if post.status == 'approved' %}ring-2 ring-blue-500{% 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Als bearbeitet markieren
</button>
<button onclick="updateStatus('published')" class="w-full px-4 py-3 bg-green-600/20 hover:bg-green-600/30 text-green-300 rounded-lg transition-colors flex items-center justify-center gap-2 {% if post.status == 'published' %}ring-2 ring-green-500{% 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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Als veröffentlicht markieren
</button>
<button onclick="updateStatus('draft')" class="w-full px-4 py-3 bg-yellow-600/20 hover:bg-yellow-600/30 text-yellow-300 rounded-lg transition-colors flex items-center justify-center gap-2 {% if post.status == 'draft' %}ring-2 ring-yellow-500{% 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.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
Zurück zu Vorschlag
</button>
</div>
</div>
<!-- Image Upload -->
<div class="section-card rounded-xl p-6">
<h3 class="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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
Bild
</h3>
<div id="imageUploadZone" class="image-upload-zone {% if post.image_url %}hidden{% endif %}">
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
<p class="text-sm text-gray-400">Bild hierher ziehen oder <span class="text-brand-highlight cursor-pointer">durchsuchen</span></p>
<p class="text-xs text-gray-500 mt-1">JPEG, PNG, GIF, WebP - max. 5 MB</p>
</div>
<div id="imageUploadProgress" class="hidden">
<div class="image-upload-progress">
<div id="imageProgressBar" class="image-upload-progress-bar" style="width: 0%"></div>
</div>
<p class="text-xs text-gray-400 mt-1 text-center">Wird hochgeladen...</p>
</div>
<div id="imagePreviewSection" class="{% if not post.image_url %}hidden{% endif %}">
<div class="image-preview-container mb-3">
<img id="sidebarImagePreview" src="{{ post.image_url or '' }}" alt="Post-Bild">
</div>
<div class="flex gap-2">
<button onclick="document.getElementById('imageReplaceInput').click()" class="flex-1 px-3 py-2 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg transition-colors text-sm flex items-center justify-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="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>
Ersetzen
</button>
<button onclick="removeImage()" id="removeImageBtn" class="flex-1 px-3 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg transition-colors text-sm flex items-center justify-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="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>
Entfernen
</button>
</div>
<input type="file" id="imageReplaceInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
</div>
</div>
<!-- Post Info -->
<div class="section-card rounded-xl p-6">
<h3 class="font-semibold text-white mb-4">Details</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-400">Erstellt</span>
<span class="text-white">{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Iterationen</span>
<span class="text-white">{{ post.iterations }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Zeichen</span>
<span class="text-white">{{ post.post_content | length }}</span>
</div>
{% if post.topic_title %}
<div class="pt-3 border-t border-brand-bg-light">
<span class="text-gray-400 block mb-1">Topic</span>
<span class="text-white">{{ post.topic_title }}</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const POST_ID = '{{ post.id }}';
const EMPLOYEE_ID = '{{ employee_id }}';
function copyToClipboard() {
const content = document.querySelector('.linkedin-content').textContent;
navigator.clipboard.writeText(content).then(() => {
const btn = document.querySelector('[onclick="copyToClipboard()"]');
const original = btn.innerHTML;
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> Kopiert!';
setTimeout(() => { btn.innerHTML = original; }, 2000);
});
}
async function updateStatus(newStatus) {
try {
const formData = new FormData();
formData.append('status', newStatus);
const response = await fetch(`/api/posts/${POST_ID}/status`, {
method: 'PATCH',
body: formData
});
if (response.ok) {
location.reload();
} else {
alert('Fehler beim Aktualisieren des Status');
}
} catch (error) {
console.error('Error:', error);
alert('Fehler beim Aktualisieren des Status');
}
}
// ==================== IMAGE UPLOAD ====================
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg z-50 transition-all transform translate-y-0 opacity-100 ${
type === 'success' ? 'bg-green-600 text-white' :
type === 'error' ? 'bg-red-600 text-white' :
'bg-brand-bg-light text-white'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-2');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
async function handleImageUpload(file) {
if (!file) return;
const uploadZone = document.getElementById('imageUploadZone');
const progressEl = document.getElementById('imageUploadProgress');
const progressBar = document.getElementById('imageProgressBar');
const previewSection = document.getElementById('imagePreviewSection');
uploadZone.classList.add('hidden');
progressEl.classList.remove('hidden');
progressBar.style.width = '30%';
try {
const formData = new FormData();
formData.append('image', file);
progressBar.style.width = '60%';
const response = await fetch(`/api/posts/${POST_ID}/image`, {
method: 'POST',
body: formData
});
progressBar.style.width = '90%';
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Upload fehlgeschlagen');
}
const result = await response.json();
progressBar.style.width = '100%';
document.getElementById('linkedinPostImage').src = result.image_url;
document.getElementById('linkedinPostImage').style.display = 'block';
document.getElementById('sidebarImagePreview').src = result.image_url;
setTimeout(() => {
progressEl.classList.add('hidden');
previewSection.classList.remove('hidden');
}, 300);
showToast('Bild erfolgreich hochgeladen!', 'success');
} catch (error) {
console.error('Image upload error:', error);
showToast('Fehler: ' + error.message, 'error');
progressEl.classList.add('hidden');
uploadZone.classList.remove('hidden');
}
}
async function removeImage() {
const btn = document.getElementById('removeImageBtn');
const originalHTML = btn.innerHTML;
btn.innerHTML = '<div class="loading-spinner" style="width:16px;height:16px;"></div>';
btn.disabled = true;
try {
const response = await fetch(`/api/posts/${POST_ID}/image`, { method: 'DELETE' });
if (!response.ok) throw new Error('Löschen fehlgeschlagen');
document.getElementById('linkedinPostImage').style.display = 'none';
document.getElementById('imagePreviewSection').classList.add('hidden');
document.getElementById('imageUploadZone').classList.remove('hidden');
showToast('Bild entfernt.', 'success');
} catch (error) {
console.error('Image delete error:', error);
showToast('Fehler: ' + error.message, 'error');
} finally {
btn.innerHTML = originalHTML;
btn.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', () => {
const uploadZone = document.getElementById('imageUploadZone');
const fileInput = document.getElementById('imageFileInput');
const replaceInput = document.getElementById('imageReplaceInput');
if (uploadZone) {
uploadZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => { if (e.target.files[0]) handleImageUpload(e.target.files[0]); });
if (replaceInput) {
replaceInput.addEventListener('change', (e) => { if (e.target.files[0]) handleImageUpload(e.target.files[0]); });
}
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); });
uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('dragover'); });
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
if (e.dataTransfer.files[0]) handleImageUpload(e.dataTransfer.files[0]);
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,286 @@
{% extends "company_base.html" %}
{% block title %}Posts - {{ employee_name }} - {{ session.company_name }}{% endblock %}
{% macro render_post_card(post) %}
<div class="post-card"
draggable="true"
data-post-id="{{ post.id }}"
ondragstart="handleDragStart(event)"
ondragend="handleDragEnd(event)"
onclick="window.location.href='/company/manage/post/{{ post.id }}?employee_id={{ employee_id }}'">
<div class="flex items-start justify-between gap-2 mb-2">
<h4 class="post-card-title">{{ post.topic_title or 'Untitled' }}</h4>
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
{% set score = post.critic_feedback[-1].overall_score %}
<span class="score-badge flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
{{ score }}
</span>
{% endif %}
</div>
<div class="post-card-meta">
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}
</span>
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.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>
{{ post.iterations }}x
</span>
</div>
{% if post.post_content %}
<p class="post-card-preview">{{ post.post_content[:150] }}{% if post.post_content | length > 150 %}...{% endif %}</p>
{% endif %}
</div>
{% endmacro %}
{% block head %}
<style>
.kanban-board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
min-height: calc(100vh - 300px);
}
@media (max-width: 1024px) {
.kanban-board { grid-template-columns: 1fr; }
}
.kanban-column {
background: rgba(45, 56, 56, 0.3);
border: 1px solid rgba(61, 72, 72, 0.6);
border-radius: 1rem;
display: flex;
flex-direction: column;
min-height: 400px;
}
.kanban-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(61, 72, 72, 0.6);
display: flex;
align-items: center;
justify-content: space-between;
}
.kanban-header h3 { font-weight: 600; display: flex; align-items: center; gap: 0.5rem; }
.kanban-count { background: rgba(61, 72, 72, 0.8); padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }
.kanban-body { flex: 1; padding: 1rem; overflow-y: auto; min-height: 100px; }
.kanban-body.drag-over { background: rgba(255, 199, 0, 0.05); border: 2px dashed rgba(255, 199, 0, 0.3); border-radius: 0.5rem; margin: 0.5rem; }
.post-card {
background: linear-gradient(135deg, rgba(61, 72, 72, 0.5) 0%, rgba(45, 56, 56, 0.6) 100%);
border: 1px solid rgba(61, 72, 72, 0.8);
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 0.75rem;
cursor: grab;
transition: all 0.2s ease;
}
.post-card:hover { border-color: rgba(255, 199, 0, 0.4); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
.post-card.dragging { opacity: 0.5; cursor: grabbing; }
.post-card-title { font-weight: 500; color: white; margin-bottom: 0.5rem; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.post-card-meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: #9ca3af; }
.post-card-preview { font-size: 0.8rem; color: #9ca3af; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid rgba(61, 72, 72, 0.6); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.4; }
.score-badge { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.7rem; font-weight: 600; }
.score-high { background: rgba(34, 197, 94, 0.2); color: #86efac; }
.score-medium { background: rgba(234, 179, 8, 0.2); color: #fde047; }
.score-low { background: rgba(239, 68, 68, 0.2); color: #fca5a5; }
.column-draft .kanban-header { border-left: 3px solid #f59e0b; }
.column-approved .kanban-header { border-left: 3px solid #3b82f6; }
.column-ready .kanban-header { border-left: 3px solid #22c55e; }
.empty-column { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; color: #6b7280; text-align: center; }
.empty-column svg { width: 3rem; height: 3rem; margin-bottom: 0.75rem; opacity: 0.5; }
</style>
{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<div class="mb-6">
<nav class="flex items-center gap-2 text-sm">
<a href="/company/manage" class="text-gray-400 hover:text-white transition-colors">Inhalte verwalten</a>
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<a href="/company/manage?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">{{ employee_name }}</a>
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<span class="text-white">Posts</span>
</nav>
</div>
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-white mb-1">Posts von {{ employee_name }}</h1>
<p class="text-gray-400 text-sm">Ziehe Posts zwischen den Spalten um den Status zu ändern</p>
</div>
<a href="/company/manage/create?employee_id={{ employee_id }}" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
<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 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
Neuer Post
</a>
</div>
<div class="kanban-board">
<!-- Column: Vorschlag (draft) -->
<div class="kanban-column column-draft">
<div class="kanban-header">
<h3 class="text-yellow-400">
<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.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
Vorschlag
</h3>
<span class="kanban-count" id="count-draft">{{ posts | selectattr('status', 'equalto', 'draft') | list | length }}</span>
</div>
<div class="kanban-body" data-status="draft" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
{% for post in posts if post.status == 'draft' %}
{{ render_post_card(post) }}
{% else %}
<div class="empty-column" id="empty-draft">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
<p>Keine Vorschläge</p>
</div>
{% endfor %}
</div>
</div>
<!-- Column: Bearbeitet (approved) - waiting for customer approval -->
<div class="kanban-column column-approved">
<div class="kanban-header">
<h3 class="text-blue-400">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Bearbeitet
</h3>
<span class="kanban-count" id="count-approved">{{ posts | selectattr('status', 'equalto', 'approved') | list | length }}</span>
</div>
<div class="kanban-body" data-status="approved" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
{% for post in posts if post.status == 'approved' %}
{{ render_post_card(post) }}
{% else %}
<div class="empty-column" id="empty-approved">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
<p>Keine bearbeiteten Posts</p>
</div>
{% endfor %}
</div>
</div>
<!-- Column: Freigegeben (ready) - approved by customer, ready for calendar scheduling -->
<div class="kanban-column column-ready">
<div class="kanban-header">
<h3 class="text-green-400">
<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>
Freigegeben
</h3>
<span class="kanban-count" id="count-ready">{{ posts | selectattr('status', 'equalto', 'ready') | list | length }}</span>
</div>
<div class="kanban-body" data-status="ready" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
{% for post in posts if post.status == 'ready' %}
{{ render_post_card(post) }}
{% else %}
<div class="empty-column" id="empty-ready">
<svg 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>
<p>Keine freigegebenen Posts</p>
</div>
{% endfor %}
</div>
</div>
</div>
{% if not posts %}
<div class="card-bg rounded-xl border p-12 text-center mt-6">
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</div>
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Posts</h3>
<p class="text-gray-400 mb-6 max-w-md mx-auto">Erstelle den ersten LinkedIn Post für {{ employee_name }}.</p>
<a href="/company/manage/create?employee_id={{ employee_id }}" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg 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 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
Post erstellen
</a>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
let draggedElement = null;
let sourceStatus = null;
function handleDragStart(e) {
draggedElement = e.target;
sourceStatus = e.target.closest('.kanban-body').dataset.status;
e.target.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', e.target.dataset.postId);
}
function handleDragEnd(e) {
e.target.classList.remove('dragging');
document.querySelectorAll('.kanban-body').forEach(body => body.classList.remove('drag-over'));
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const kanbanBody = e.target.closest('.kanban-body');
if (kanbanBody) kanbanBody.classList.add('drag-over');
}
function handleDragLeave(e) {
const kanbanBody = e.target.closest('.kanban-body');
if (kanbanBody && !kanbanBody.contains(e.relatedTarget)) kanbanBody.classList.remove('drag-over');
}
async function handleDrop(e) {
e.preventDefault();
const kanbanBody = e.target.closest('.kanban-body');
if (!kanbanBody || !draggedElement) return;
kanbanBody.classList.remove('drag-over');
const newStatus = kanbanBody.dataset.status;
const postId = draggedElement.dataset.postId;
if (sourceStatus === newStatus) return;
const emptyPlaceholder = kanbanBody.querySelector('.empty-column');
if (emptyPlaceholder) emptyPlaceholder.remove();
kanbanBody.appendChild(draggedElement);
const sourceBody = document.querySelector(`.kanban-body[data-status="${sourceStatus}"]`);
if (sourceBody && sourceBody.querySelectorAll('.post-card').length === 0) {
addEmptyPlaceholder(sourceBody, sourceStatus);
}
updateCounts();
try {
const formData = new FormData();
formData.append('status', newStatus);
const response = await fetch(`/api/posts/${postId}/status`, {
method: 'PATCH',
body: formData
});
if (!response.ok) throw new Error('Failed to update status');
} catch (error) {
console.error('Error updating status:', error);
location.reload();
}
}
function addEmptyPlaceholder(container, status) {
const icons = {
'draft': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>',
'approved': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>',
'ready': '<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"/>'
};
const labels = { 'draft': 'Keine Vorschläge', 'approved': 'Keine bearbeiteten Posts', 'ready': 'Keine freigegebenen Posts' };
const placeholder = document.createElement('div');
placeholder.className = 'empty-column';
placeholder.id = `empty-${status}`;
placeholder.innerHTML = `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">${icons[status]}</svg><p>${labels[status]}</p>`;
container.appendChild(placeholder);
}
function updateCounts() {
['draft', 'approved', 'ready'].forEach(status => {
const count = document.querySelectorAll(`.kanban-body[data-status="${status}"] .post-card`).length;
document.getElementById(`count-${status}`).textContent = count;
});
}
</script>
{% endblock %}

View File

@@ -1,31 +1,47 @@
{% extends "base.html" %} {% extends "company_base.html" %}
{% block title %}Research Topics - LinkedIn Posts{% endblock %} {% block title %}Research - {{ employee_name }} - {{ session.company_name }}{% endblock %}
{% block content %} {% block content %}
<!-- Breadcrumb -->
<div class="mb-6">
<nav class="flex items-center gap-2 text-sm">
<a href="/company/manage" class="text-gray-400 hover:text-white transition-colors">Inhalte verwalten</a>
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<a href="/company/manage?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">{{ employee_name }}</a>
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<span class="text-white">Research</span>
</nav>
</div>
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Research Topics</h1> <h1 class="text-3xl font-bold text-white mb-2">Research Topics</h1>
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p> <p class="text-gray-400">Recherchiere neue Content-Themen für {{ employee_name }}</p>
</div> </div>
<!-- Limit Warning -->
{% if limit_reached %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl mb-8">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 flex-shrink-0" 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>
<strong class="font-semibold">Limit erreicht</strong>
<p class="text-sm mt-1">{{ limit_message }}</p>
</div>
</div>
</div>
{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left: Form --> <!-- Left: Form -->
<div> <div>
<form id="researchForm" class="card-bg rounded-xl border p-6"> <form id="researchForm" class="card-bg rounded-xl border p-6">
<div class="mb-6">
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
<option value="">-- Kunde wählen --</option>
{% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
{% endfor %}
</select>
</div>
<!-- Post Type Selection --> <!-- Post Type Selection -->
<div id="postTypeArea" class="mb-6 hidden"> <div id="postTypeArea" class="mb-6">
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ (optional)</label> <label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ (optional)</label>
<div id="postTypeCards" class="grid grid-cols-2 gap-2 mb-2"> <div id="postTypeCards" class="grid grid-cols-2 gap-2 mb-2">
<!-- Post type cards will be loaded here --> <div class="text-gray-500 text-sm">Lade Post-Typen...</div>
</div> </div>
<p class="text-xs text-gray-500">Wähle einen Post-Typ für gezielte Recherche oder lasse leer für allgemeine Recherche.</p> <p class="text-xs text-gray-500">Wähle einen Post-Typ für gezielte Recherche oder lasse leer für allgemeine Recherche.</p>
<input type="hidden" name="post_type_id" id="selectedPostTypeId" value=""> <input type="hidden" name="post_type_id" id="selectedPostTypeId" value="">
@@ -44,17 +60,12 @@
</div> </div>
</div> </div>
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2"> <button type="submit" id="submitBtn" {% if limit_reached %}disabled{% endif %}
class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
Research starten {% if limit_reached %}Limit erreicht{% else %}Research starten{% endif %}
</button> </button>
</form> </form>
{% if not customers %}
<div class="mt-4 bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/admin/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
</div>
{% endif %}
</div> </div>
<!-- Right: Results --> <!-- Right: Results -->
@@ -71,6 +82,9 @@
{% block scripts %} {% block scripts %}
<script> <script>
const USER_ID = '{{ user_id }}';
const EMPLOYEE_ID = '{{ employee_id }}';
const form = document.getElementById('researchForm'); const form = document.getElementById('researchForm');
const submitBtn = document.getElementById('submitBtn'); const submitBtn = document.getElementById('submitBtn');
const progressArea = document.getElementById('progressArea'); const progressArea = document.getElementById('progressArea');
@@ -78,30 +92,19 @@ const progressBar = document.getElementById('progressBar');
const progressMessage = document.getElementById('progressMessage'); const progressMessage = document.getElementById('progressMessage');
const progressPercent = document.getElementById('progressPercent'); const progressPercent = document.getElementById('progressPercent');
const topicsList = document.getElementById('topicsList'); const topicsList = document.getElementById('topicsList');
const customerSelect = document.getElementById('customerSelect');
const postTypeArea = document.getElementById('postTypeArea');
const postTypeCards = document.getElementById('postTypeCards'); const postTypeCards = document.getElementById('postTypeCards');
const selectedPostTypeId = document.getElementById('selectedPostTypeId'); const selectedPostTypeId = document.getElementById('selectedPostTypeId');
let currentPostTypes = []; let currentPostTypes = [];
// Load post types when customer is selected // Load post types on page load
customerSelect.addEventListener('change', async () => { async function loadPostTypes() {
const customerId = customerSelect.value;
selectedPostTypeId.value = '';
if (!customerId) {
postTypeArea.classList.add('hidden');
return;
}
try { try {
const response = await fetch(`/admin/api/customers/${customerId}/post-types`); const response = await fetch(`/api/post-types?user_id=${USER_ID}`);
const data = await response.json(); const data = await response.json();
if (data.post_types && data.post_types.length > 0) { if (data.post_types && data.post_types.length > 0) {
currentPostTypes = data.post_types; currentPostTypes = data.post_types;
postTypeArea.classList.remove('hidden');
postTypeCards.innerHTML = ` postTypeCards.innerHTML = `
<button type="button" onclick="selectPostType('')" id="pt_all" <button type="button" onclick="selectPostType('')" id="pt_all"
@@ -118,18 +121,17 @@ customerSelect.addEventListener('change', async () => {
</button> </button>
`).join(''); `).join('');
} else { } else {
postTypeArea.classList.add('hidden'); postTypeCards.innerHTML = '<p class="text-gray-500 text-sm col-span-2">Keine Post-Typen konfiguriert.</p>';
} }
} catch (error) { } catch (error) {
console.error('Failed to load post types:', error); console.error('Failed to load post types:', error);
postTypeArea.classList.add('hidden'); postTypeCards.innerHTML = '<p class="text-red-400 text-sm col-span-2">Fehler beim Laden.</p>';
} }
}); }
function selectPostType(typeId) { function selectPostType(typeId) {
selectedPostTypeId.value = typeId; selectedPostTypeId.value = typeId;
// Update card styles
document.querySelectorAll('[id^="pt_"]').forEach(card => { document.querySelectorAll('[id^="pt_"]').forEach(card => {
if (card.id === `pt_${typeId}` || (typeId === '' && card.id === 'pt_all')) { if (card.id === `pt_${typeId}` || (typeId === '' && card.id === 'pt_all')) {
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white'; card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
@@ -142,21 +144,18 @@ function selectPostType(typeId) {
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const customerId = customerSelect.value;
if (!customerId) return;
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" 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> Recherchiert...'; submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" 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> Recherchiert...';
progressArea.classList.remove('hidden'); progressArea.classList.remove('hidden');
const formData = new FormData(); const formData = new FormData();
formData.append('customer_id', customerId); formData.append('user_id', USER_ID);
if (selectedPostTypeId.value) { if (selectedPostTypeId.value) {
formData.append('post_type_id', selectedPostTypeId.value); formData.append('post_type_id', selectedPostTypeId.value);
} }
try { try {
const response = await fetch('/admin/api/research', { const response = await fetch('/api/research', {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
@@ -164,7 +163,7 @@ form.addEventListener('submit', async (e) => {
const taskId = data.task_id; const taskId = data.task_id;
const pollInterval = setInterval(async () => { const pollInterval = setInterval(async () => {
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`); const statusResponse = await fetch(`/api/tasks/${taskId}`);
const status = await statusResponse.json(); const status = await statusResponse.json();
progressBar.style.width = `${status.progress}%`; progressBar.style.width = `${status.progress}%`;
@@ -177,7 +176,6 @@ form.addEventListener('submit', async (e) => {
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.innerHTML = '<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten'; submitBtn.innerHTML = '<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
// Display topics
if (status.topics && status.topics.length > 0) { if (status.topics && status.topics.length > 0) {
topicsList.innerHTML = status.topics.map((topic, i) => ` topicsList.innerHTML = status.topics.map((topic, i) => `
<div class="bg-brand-bg rounded-lg p-4 border border-brand-bg-light"> <div class="bg-brand-bg rounded-lg p-4 border border-brand-bg-light">
@@ -211,5 +209,8 @@ form.addEventListener('submit', async (e) => {
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`; topicsList.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
} }
}); });
// Load post types on page load
loadPostTypes();
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,260 @@
{% extends "company_base.html" %}
{% block title %}Strategie - {{ session.company_name }}{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto">
<h1 class="text-2xl font-bold text-white mb-2">Unternehmensstrategie</h1>
<p class="text-gray-400 mb-8">Diese Strategie wird bei der Erstellung aller LinkedIn-Posts deiner Mitarbeiter berücksichtigt.</p>
{% if success %}
<div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded-lg mb-6">
Strategie erfolgreich gespeichert!
</div>
{% endif %}
{% 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 %}
<form method="POST" action="/company/strategy" class="space-y-6">
<!-- Mission & Vision -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Mission & Vision</h2>
<div class="space-y-4">
<div>
<label for="mission" class="block text-sm font-medium text-gray-300 mb-1">Mission</label>
<textarea id="mission" name="mission" rows="2"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Was ist der Zweck deines Unternehmens?">{{ strategy.mission or '' }}</textarea>
</div>
<div>
<label for="vision" class="block text-sm font-medium text-gray-300 mb-1">Vision</label>
<textarea id="vision" name="vision" rows="2"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Wo soll dein Unternehmen in Zukunft stehen?">{{ strategy.vision or '' }}</textarea>
</div>
</div>
</div>
<!-- Brand Voice -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Brand Voice</h2>
<div class="space-y-4">
<div>
<label for="brand_voice" class="block text-sm font-medium text-gray-300 mb-1">Markenstimme</label>
<textarea id="brand_voice" name="brand_voice" rows="2"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Wie soll deine Marke klingen? (z.B. professionell, freundlich, innovativ)">{{ strategy.brand_voice or '' }}</textarea>
</div>
<div>
<label for="tone_guidelines" class="block text-sm font-medium text-gray-300 mb-1">Tonalität-Richtlinien</label>
<textarea id="tone_guidelines" name="tone_guidelines" rows="3"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Spezifische Anweisungen zur Tonalität (z.B. Duzen/Siezen, Fachsprache, etc.)">{{ strategy.tone_guidelines or '' }}</textarea>
</div>
</div>
</div>
<!-- Target Audience -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Zielgruppe</h2>
<div>
<label for="target_audience" class="block text-sm font-medium text-gray-300 mb-1">Beschreibung der Zielgruppe</label>
<textarea id="target_audience" name="target_audience" rows="3"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Wer ist deine Zielgruppe auf LinkedIn?">{{ strategy.target_audience or '' }}</textarea>
</div>
</div>
<!-- Content Pillars -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Content-Säulen</h2>
<p class="text-gray-400 text-sm mb-4">Die Hauptthemen, über die dein Unternehmen postet.</p>
<div id="content-pillars" class="space-y-2">
{% for pillar in strategy.content_pillars or [] %}
<div class="flex gap-2">
<input type="text" name="content_pillar" value="{{ pillar }}"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="z.B. Innovation, Nachhaltigkeit, Teamkultur">
<button type="button" onclick="this.parentElement.remove()"
class="px-3 py-2 text-red-400 hover:text-red-300">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endfor %}
{% if not strategy.content_pillars %}
<div class="flex gap-2">
<input type="text" name="content_pillar"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="z.B. Innovation, Nachhaltigkeit, Teamkultur">
<button type="button" onclick="this.parentElement.remove()"
class="px-3 py-2 text-red-400 hover:text-red-300">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endif %}
</div>
<button type="button" onclick="addPillar()"
class="mt-3 text-brand-highlight text-sm hover:underline flex items-center gap-1">
<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="M12 4v16m8-8H4"/>
</svg>
Weitere Säule hinzufügen
</button>
</div>
<!-- Do's and Don'ts -->
<div class="grid md:grid-cols-2 gap-6">
<!-- Do's -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-green-400 mb-4">Do's</h2>
<div id="dos-list" class="space-y-2">
{% for do in strategy.dos or [] %}
<div class="flex gap-2">
<input type="text" name="do_item" value="{{ do }}"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
placeholder="z.B. Erfolge feiern">
<button type="button" onclick="this.parentElement.remove()"
class="px-2 py-2 text-red-400 hover:text-red-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>
{% endfor %}
{% if not strategy.dos %}
<div class="flex gap-2">
<input type="text" name="do_item"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
placeholder="z.B. Erfolge feiern">
<button type="button" onclick="this.parentElement.remove()"
class="px-2 py-2 text-red-400 hover:text-red-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>
{% endif %}
</div>
<button type="button" onclick="addDo()"
class="mt-3 text-green-400 text-sm hover:underline flex items-center gap-1">
<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="M12 4v16m8-8H4"/>
</svg>
Hinzufügen
</button>
</div>
<!-- Don'ts -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-red-400 mb-4">Don'ts</h2>
<div id="donts-list" class="space-y-2">
{% for dont in strategy.donts or [] %}
<div class="flex gap-2">
<input type="text" name="dont_item" value="{{ dont }}"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
placeholder="z.B. Konkurrenz kritisieren">
<button type="button" onclick="this.parentElement.remove()"
class="px-2 py-2 text-red-400 hover:text-red-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>
{% endfor %}
{% if not strategy.donts %}
<div class="flex gap-2">
<input type="text" name="dont_item"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
placeholder="z.B. Konkurrenz kritisieren">
<button type="button" onclick="this.parentElement.remove()"
class="px-2 py-2 text-red-400 hover:text-red-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>
{% endif %}
</div>
<button type="button" onclick="addDont()"
class="mt-3 text-red-400 text-sm hover:underline flex items-center gap-1">
<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="M12 4v16m8-8H4"/>
</svg>
Hinzufügen
</button>
</div>
</div>
<!-- Submit Button -->
<div class="flex justify-end">
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
Strategie speichern
</button>
</div>
</form>
</div>
<script>
function addPillar() {
const container = document.getElementById('content-pillars');
const div = document.createElement('div');
div.className = 'flex gap-2';
div.innerHTML = `
<input type="text" name="content_pillar"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="z.B. Innovation, Nachhaltigkeit, Teamkultur">
<button type="button" onclick="this.parentElement.remove()"
class="px-3 py-2 text-red-400 hover:text-red-300">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
`;
container.appendChild(div);
}
function addDo() {
const container = document.getElementById('dos-list');
const div = document.createElement('div');
div.className = 'flex gap-2';
div.innerHTML = `
<input type="text" name="do_item"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
placeholder="z.B. Erfolge feiern">
<button type="button" onclick="this.parentElement.remove()"
class="px-2 py-2 text-red-400 hover:text-red-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>
`;
container.appendChild(div);
}
function addDont() {
const container = document.getElementById('donts-list');
const div = document.createElement('div');
div.className = 'flex gap-2';
div.innerHTML = `
<input type="text" name="dont_item"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
placeholder="z.B. Konkurrenz kritisieren">
<button type="button" onclick="this.parentElement.remove()"
class="px-2 py-2 text-red-400 hover:text-red-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>
`;
container.appendChild(div);
}
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a2424 0%, #2d3838 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
max-width: 500px;
width: 100%;
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.header {
padding: 32px;
text-align: center;
}
.header.success {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
}
.header.error {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
}
.icon {
width: 64px;
height: 64px;
margin: 0 auto 16px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.icon svg {
width: 32px;
height: 32px;
color: white;
}
.header h1 {
color: white;
font-size: 24px;
font-weight: 600;
}
.content {
padding: 32px;
text-align: center;
}
.message {
font-size: 16px;
color: #4b5563;
line-height: 1.6;
margin-bottom: 24px;
}
.action-badge {
display: inline-block;
padding: 8px 20px;
border-radius: 20px;
font-weight: 600;
font-size: 14px;
margin-bottom: 24px;
}
.action-badge.approved {
background: #dcfce7;
color: #166534;
}
.action-badge.rejected {
background: #fef3c7;
color: #92400e;
}
.info-box {
background: #f8fafc;
border-radius: 12px;
padding: 20px;
margin-top: 16px;
}
.info-box p {
font-size: 14px;
color: #64748b;
}
.footer {
padding: 20px 32px;
background: #f8fafc;
text-align: center;
border-top: 1px solid #e2e8f0;
}
.footer p {
font-size: 12px;
color: #94a3b8;
}
</style>
</head>
<body>
<div class="container">
<div class="header {% if success %}success{% else %}error{% endif %}">
<div class="icon">
{% if success %}
<svg 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>
{% else %}
<svg 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>
{% endif %}
</div>
<h1>{{ title }}</h1>
</div>
<div class="content">
{% if success and action %}
<div class="action-badge {% if action == 'approve' %}approved{% else %}rejected{% endif %}">
{% if action == 'approve' %}
Freigegeben
{% else %}
Zur Überarbeitung
{% endif %}
</div>
{% endif %}
<p class="message">{{ message }}</p>
{% if success %}
<div class="info-box">
{% if action == 'approve' %}
<p>Der Post wurde in die Spalte "Veröffentlicht" verschoben und kann nun auf LinkedIn gepostet werden.</p>
{% else %}
<p>Der Post wurde zurück in die Spalte "Vorschläge" verschoben. Der Creator wurde benachrichtigt und wird die gewünschten Änderungen vornehmen.</p>
{% endif %}
</div>
{% endif %}
</div>
<div class="footer">
<p>Du kannst dieses Fenster jetzt schließen.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Dashboard{% endblock %} - LinkedIn Post Generator</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'brand-bg': '#1a1a2e',
'brand-bg-dark': '#0f0f1a',
'brand-bg-light': '#252540',
'brand-highlight': '#e94560',
'brand-accent': '#0f3460',
}
}
}
}
</script>
<style>
body {
background-color: #0f0f1a;
}
.sidebar-link {
@apply flex items-center gap-3 px-4 py-3 text-gray-300 hover:bg-brand-bg-light rounded-lg transition-colors;
}
.sidebar-link.active {
@apply bg-brand-bg-light text-white;
}
.card-bg {
background-color: #1a1a2e;
border-color: #2a2a4a;
}
.input-bg {
background-color: #252540;
border-color: #3a3a5a;
}
.input-bg:focus {
border-color: #e94560;
outline: none;
}
.btn-primary {
background-color: #e94560;
}
.btn-primary:hover {
background-color: #d13a52;
}
</style>
{% block head %}{% endblock %}
</head>
<body class="min-h-screen">
<div class="flex min-h-screen">
<!-- Sidebar -->
<aside class="w-64 bg-brand-bg border-r border-gray-700 flex flex-col">
<!-- Logo/Brand -->
<div class="p-6 border-b border-gray-700">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-brand-highlight rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</div>
<div>
<h1 class="text-white font-bold">{{ session.linkedin_name or session.customer_name or 'Mitarbeiter' }}</h1>
<p class="text-gray-500 text-xs">Mitarbeiter</p>
</div>
</div>
</div>
<!-- Company Info -->
{% if session.company_name %}
<div class="px-6 py-4 border-b border-gray-700">
<p class="text-xs text-gray-500 uppercase tracking-wide mb-1">Unternehmen</p>
<p class="text-brand-highlight font-medium">{{ session.company_name }}</p>
</div>
{% endif %}
<!-- Navigation -->
<nav class="flex-1 p-4 space-y-1">
<a href="/" class="sidebar-link {% if request.url.path == '/' %}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="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"/>
</svg>
Dashboard
</a>
<a href="/posts" class="sidebar-link {% if '/posts' in request.url.path %}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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Meine Posts
</a>
<a href="/create-post" class="sidebar-link {% if '/create-post' in request.url.path %}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="M12 4v16m8-8H4"/>
</svg>
Neuer Post
</a>
<a href="/employee/strategy" class="sidebar-link {% if '/employee/strategy' in request.url.path %}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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Unternehmensstrategie
</a>
<a href="/settings" class="sidebar-link {% if '/settings' in request.url.path %}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="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>
</nav>
<!-- Logout -->
<div class="p-4 border-t border-gray-700">
<a href="/logout" class="sidebar-link text-red-400 hover:text-red-300 hover:bg-red-900/20">
<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 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>
Abmelden
</a>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 overflow-auto">
<div class="p-8">
{% block content %}{% endblock %}
</div>
</main>
</div>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-white mb-6">Willkommen, {{ session.linkedin_name or session.customer_name or 'Mitarbeiter' }}!</h1>
<!-- Company Info Banner -->
{% if session.company_name %}
<div class="bg-brand-highlight/10 border border-brand-highlight/30 rounded-xl p-6 mb-8">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-brand-highlight rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-brand-bg-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<div>
<p class="text-gray-400 text-sm">Du bist Mitarbeiter von</p>
<p class="text-xl font-bold text-brand-highlight">{{ session.company_name }}</p>
</div>
</div>
</div>
{% endif %}
<!-- Stats Cards -->
<div class="grid md:grid-cols-3 gap-6 mb-8">
<!-- Posts Card -->
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div>
<p class="text-3xl font-bold text-white">{{ posts_count or 0 }}</p>
<p class="text-gray-400 text-sm">Erstellte Posts</p>
</div>
</div>
</div>
<!-- Pending Posts Card -->
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-yellow-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-yellow-400" 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>
</div>
<div>
<p class="text-3xl font-bold text-white">{{ pending_posts or 0 }}</p>
<p class="text-gray-400 text-sm">Ausstehend</p>
</div>
</div>
</div>
<!-- Approved Posts Card -->
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-400" 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>
</div>
<div>
<p class="text-3xl font-bold text-white">{{ approved_posts or 0 }}</p>
<p class="text-gray-400 text-sm">Genehmigt</p>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card-bg border rounded-xl p-6 mb-8">
<h2 class="text-lg font-semibold text-white mb-4">Schnellzugriff</h2>
<div class="grid md:grid-cols-3 gap-4">
<a href="/research" class="flex items-center gap-3 p-4 bg-brand-bg-dark rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<div>
<p class="text-white font-medium">Research Topics</p>
<p class="text-gray-400 text-sm">Themen recherchieren</p>
</div>
</a>
<a href="/create" class="flex items-center gap-3 p-4 bg-brand-bg-dark rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="M12 4v16m8-8H4"/>
</svg>
</div>
<div>
<p class="text-white font-medium">Neuer Post</p>
<p class="text-gray-400 text-sm">KI-generiert</p>
</div>
</a>
<a href="/employee/strategy" class="flex items-center gap-3 p-4 bg-brand-bg-dark rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="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>
</div>
<div>
<p class="text-white font-medium">Strategie</p>
<p class="text-gray-400 text-sm">Richtlinien</p>
</div>
</a>
</div>
</div>
<!-- Recent Posts -->
{% if recent_posts and recent_posts|length > 0 %}
<div class="card-bg border rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Letzte Posts</h2>
<a href="/posts" class="text-brand-highlight text-sm hover:underline">Alle anzeigen</a>
</div>
<div class="space-y-3">
{% for post in recent_posts[:5] %}
<div class="flex items-start gap-3 p-3 bg-brand-bg-dark rounded-lg">
<div class="flex-1">
<p class="text-white text-sm line-clamp-2">{{ post.post_content[:150] }}{% if post.post_content|length > 150 %}...{% endif %}</p>
<p class="text-gray-500 text-xs mt-1">{{ post.created_at.strftime('%d.%m.%Y') }}</p>
</div>
<span class="text-xs px-2 py-1 rounded {% if post.status == 'approved' %}bg-green-500/20 text-green-400{% elif post.status == 'rejected' %}bg-red-500/20 text-red-400{% else %}bg-yellow-500/20 text-yellow-400{% endif %}">
{% if post.status == 'approved' %}Genehmigt{% elif post.status == 'rejected' %}Abgelehnt{% else %}Ausstehend{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="card-bg border rounded-xl p-6 text-center">
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<p class="text-gray-400">Noch keine Posts erstellt</p>
<a href="/create" class="inline-block mt-4 text-brand-highlight hover:underline">Ersten Post erstellen</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,139 @@
{% extends "base.html" %}
{% block title %}Unternehmensstrategie - {{ session.company_name }}{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto">
<div class="mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Unternehmensstrategie</h1>
<p class="text-gray-400">Diese Strategie wird bei der Erstellung deiner LinkedIn-Posts berücksichtigt.</p>
{% if session.company_name %}
<p class="text-brand-highlight text-sm mt-2">Richtlinien von {{ session.company_name }}</p>
{% endif %}
</div>
{% if not strategy or not strategy.mission %}
<div class="card-bg border rounded-xl p-8 text-center">
<div class="w-16 h-16 bg-yellow-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-yellow-400" 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-gray-400">Dein Unternehmen hat noch keine Strategie definiert.</p>
<p class="text-gray-500 text-sm mt-2">Wende dich an deinen Administrator, um die Unternehmensstrategie einzurichten.</p>
</div>
{% else %}
<div class="space-y-6">
<!-- Mission & Vision -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Mission & Vision</h2>
<div class="space-y-4">
{% if strategy.mission %}
<div>
<p class="text-sm font-medium text-gray-400 mb-1">Mission</p>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.mission }}</p>
</div>
{% endif %}
{% if strategy.vision %}
<div>
<p class="text-sm font-medium text-gray-400 mb-1">Vision</p>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.vision }}</p>
</div>
{% endif %}
</div>
</div>
<!-- Brand Voice -->
{% if strategy.brand_voice or strategy.tone_guidelines %}
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Brand Voice</h2>
<div class="space-y-4">
{% if strategy.brand_voice %}
<div>
<p class="text-sm font-medium text-gray-400 mb-1">Markenstimme</p>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.brand_voice }}</p>
</div>
{% endif %}
{% if strategy.tone_guidelines %}
<div>
<p class="text-sm font-medium text-gray-400 mb-1">Tonalität-Richtlinien</p>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.tone_guidelines }}</p>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Target Audience -->
{% if strategy.target_audience %}
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Zielgruppe</h2>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.target_audience }}</p>
</div>
{% endif %}
<!-- Content Pillars -->
{% if strategy.content_pillars and strategy.content_pillars|length > 0 %}
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Content-Säulen</h2>
<p class="text-gray-400 text-sm mb-4">Die Hauptthemen, über die das Unternehmen postet.</p>
<div class="flex flex-wrap gap-2">
{% for pillar in strategy.content_pillars %}
<span class="px-3 py-2 bg-brand-highlight/20 border border-brand-highlight/30 rounded-lg text-brand-highlight text-sm">{{ pillar }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Do's and Don'ts -->
{% if (strategy.dos and strategy.dos|length > 0) or (strategy.donts and strategy.donts|length > 0) %}
<div class="grid md:grid-cols-2 gap-6">
<!-- Do's -->
{% if strategy.dos and strategy.dos|length > 0 %}
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-green-400 mb-4 flex items-center gap-2">
<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="M5 13l4 4L19 7"/>
</svg>
Do's
</h2>
<ul class="space-y-2">
{% for do in strategy.dos %}
<li class="flex items-start gap-2 text-white text-sm">
<svg class="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" 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>
{{ do }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Don'ts -->
{% if strategy.donts and strategy.donts|length > 0 %}
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-red-400 mb-4 flex items-center gap-2">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
Don'ts
</h2>
<ul class="space-y-2">
{% for dont in strategy.donts %}
<li class="flex items-start gap-2 text-white text-sm">
<svg class="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" 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>
{{ dont }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einladung annehmen - LinkedIn Posts</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'brand': {
'bg': '#3d4848',
'bg-light': '#4a5858',
'bg-dark': '#2d3838',
'highlight': '#ffc700',
'highlight-dark': '#e6b300',
},
'linkedin': '#0A66C2'
}
}
}
}
</script>
<style>
body { background-color: #3d4848; }
.btn-linkedin { background-color: #0A66C2; }
.btn-linkedin:hover { background-color: #004182; }
.card-bg { background-color: #4a5858; border-color: #5a6868; }
.input-bg { background-color: #3d4848; border-color: #5a6868; }
.input-bg:focus { border-color: #ffc700; outline: none; }
.btn-primary { background-color: #ffc700; color: #2d3838; }
.btn-primary:hover { background-color: #e6b300; }
</style>
</head>
<body class="text-gray-100 min-h-screen flex items-center justify-center">
<div class="w-full max-w-md px-4">
<div class="card-bg rounded-xl border p-8">
<div class="text-center mb-8">
<img src="/static/logo.png" alt="Logo" class="h-16 w-auto mx-auto mb-4">
<h1 class="text-2xl font-bold text-white mb-2">Einladung von {{ company_name }}</h1>
<p class="text-gray-400">Du wurdest von {{ inviter_name }} eingeladen, dem Team beizutreten.</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 expired %}
<div class="bg-yellow-900/50 border border-yellow-500 text-yellow-200 px-4 py-3 rounded-lg mb-6">
<p class="font-medium">Diese Einladung ist abgelaufen</p>
<p class="text-sm">Bitte frage nach einer neuen Einladung.</p>
</div>
{% else %}
<!-- Invitation Details -->
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4 mb-6">
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<div>
<p class="text-white font-medium">{{ company_name }}</p>
<p class="text-sm text-gray-400">Eingeladen am {{ invitation.created_at.strftime('%d.%m.%Y') }}</p>
</div>
</div>
<p class="text-sm text-gray-400">
Als Teammitglied kannst du LinkedIn-Posts erstellen, die der Unternehmensstrategie entsprechen.
</p>
</div>
<!-- LinkedIn OAuth -->
<div class="mb-6">
<a href="/auth/linkedin?invite_token={{ invitation.token }}" class="w-full btn-linkedin text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-3">
<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 beitreten
</a>
</div>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-brand-bg-light text-gray-400">oder mit E-Mail</span>
</div>
</div>
<!-- Email/Password Form -->
<form method="POST" action="/invite/{{ invitation.token }}/accept" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-300 mb-1">E-Mail</label>
<input type="email" id="email" name="email" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
value="{{ invitation.email }}" readonly>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300 mb-1">Passwort erstellen</label>
<input type="password" id="password" name="password" required minlength="8"
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Mindestens 8 Zeichen">
<p class="text-xs text-gray-500 mt-1">Mind. 8 Zeichen, 1 Großbuchstabe, 1 Zahl</p>
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-gray-300 mb-1">Passwort bestätigen</label>
<input type="password" id="password_confirm" name="password_confirm" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Passwort wiederholen">
</div>
<button type="submit" class="w-full btn-primary font-medium py-3 px-4 rounded-lg transition-colors">
Einladung annehmen
</button>
</form>
{% endif %}
<div class="text-center pt-6 mt-6 border-t border-gray-600">
<p class="text-gray-400 text-sm">
Du hast bereits ein Konto?
<a href="/login" class="text-brand-highlight hover:underline">Anmelden</a>
</p>
</div>
</div>
</div>
<script>
// Password confirmation validation
const form = document.querySelector('form');
if (form) {
const password = document.getElementById('password');
const passwordConfirm = document.getElementById('password_confirm');
form.addEventListener('submit', function(e) {
if (password.value !== passwordConfirm.value) {
e.preventDefault();
alert('Passwörter stimmen nicht überein');
}
});
}
</script>
</body>
</html>

View File

@@ -59,8 +59,35 @@
Mit LinkedIn anmelden Mit LinkedIn anmelden
</a> </a>
<div class="relative my-4">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-brand-bg-light text-gray-400">oder</span>
</div>
</div>
<!-- Email/Password Login Form -->
<form method="POST" action="/auth/login" class="space-y-4">
<div>
<input type="email" name="email" required
class="w-full bg-brand-bg border border-gray-600 rounded-lg px-4 py-2 text-white focus:border-brand-highlight focus:outline-none"
placeholder="E-Mail">
</div>
<div>
<input type="password" name="password" required
class="w-full bg-brand-bg border border-gray-600 rounded-lg px-4 py-2 text-white focus:border-brand-highlight focus:outline-none"
placeholder="Passwort">
</div>
<button type="submit" class="w-full bg-brand-highlight hover:bg-brand-highlight-dark text-brand-bg-dark font-medium py-3 px-4 rounded-lg transition-colors">
Anmelden
</button>
</form>
<p class="text-center text-gray-500 text-sm"> <p class="text-center text-gray-500 text-sm">
Melde dich mit deinem LinkedIn-Konto an, um auf das Dashboard zuzugreifen. Noch kein Konto?
<a href="/register" class="text-brand-highlight hover:underline">Jetzt registrieren</a>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Onboarding{% endblock %} - LinkedIn Posts</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'brand': {
'bg': '#3d4848',
'bg-light': '#4a5858',
'bg-dark': '#2d3838',
'highlight': '#ffc700',
'highlight-dark': '#e6b300',
}
}
}
}
}
</script>
<style>
body { background-color: #3d4848; }
.card-bg { background-color: #4a5858; border-color: #5a6868; }
.input-bg { background-color: #3d4848; border-color: #5a6868; }
.input-bg:focus { border-color: #ffc700; outline: none; }
.btn-primary { background-color: #ffc700; color: #2d3838; }
.btn-primary:hover { background-color: #e6b300; }
.btn-secondary { background-color: #5a6868; color: #fff; }
.btn-secondary:hover { background-color: #6a7878; }
.step-active { background-color: #ffc700; color: #2d3838; }
.step-done { background-color: #22c55e; color: white; }
.step-pending { background-color: #5a6868; color: #999; }
</style>
{% block head %}{% endblock %}
</head>
<body class="text-gray-100 min-h-screen">
<div class="max-w-4xl mx-auto px-4 py-8">
<!-- Header -->
<div class="text-center mb-8">
<img src="/static/logo.png" alt="Logo" class="h-12 w-auto mx-auto mb-4">
</div>
<!-- Progress Steps -->
{% if steps %}
<div class="mb-8">
<div class="flex items-center justify-center">
{% for step in steps %}
<div class="flex items-center">
<div class="flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium
{% if step.status == 'done' %}step-done
{% elif step.status == 'active' %}step-active
{% else %}step-pending{% endif %}">
{% if step.status == 'done' %}
<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>
{% else %}
{{ loop.index }}
{% endif %}
</div>
<span class="ml-2 text-sm {% if step.status == 'active' %}text-white{% else %}text-gray-500{% endif %}">
{{ step.name }}
</span>
</div>
{% if not loop.last %}
<div class="w-12 h-0.5 mx-2 {% if step.status == 'done' %}bg-green-500{% else %}bg-gray-600{% endif %}"></div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
<!-- Content Card -->
<div class="card-bg rounded-xl border p-8">
{% block content %}{% endblock %}
</div>
</div>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,193 @@
{% extends "onboarding/base.html" %}
{% block title %}Posts kategorisieren{% endblock %}
{% block content %}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Posts kategorisieren</h1>
<p class="text-gray-400">Wir ordnen deine Posts automatisch den Post-Typen zu.</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 %}
<!-- Classification Progress -->
<div id="classification-section" class="mb-8">
<div class="card-bg border border-gray-600 rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-white">Kategorisierung</h3>
<div id="classification-status" class="text-sm">
{% if classification_complete %}
<span class="text-green-400">Abgeschlossen</span>
{% else %}
<span class="text-yellow-400">Ausstehend</span>
{% endif %}
</div>
</div>
<!-- Progress Bar -->
<div class="mb-4">
<div class="h-2 bg-gray-600 rounded-full overflow-hidden">
<div id="progress-bar" class="h-full bg-brand-highlight transition-all duration-300"
style="width: {{ progress }}%"></div>
</div>
<div class="flex justify-between text-xs text-gray-400 mt-1">
<span id="progress-text">{{ classified_count }}/{{ total_posts }} Posts</span>
<span id="progress-percent">{{ progress }}%</span>
</div>
</div>
{% if not classification_complete %}
<button id="start-classification-btn"
class="btn-primary py-2 px-4 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="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 2"/>
</svg>
Automatisch kategorisieren
</button>
{% endif %}
</div>
</div>
<!-- Post Type Distribution -->
<div class="mb-8">
<h3 class="text-lg font-medium text-white mb-4">Verteilung nach Post-Typ</h3>
<div class="space-y-3" id="type-distribution">
{% for post_type in post_types %}
<div class="flex items-center gap-4">
<div class="flex-1">
<div class="flex justify-between text-sm mb-1">
<span class="text-white">{{ post_type.name }}</span>
<span class="text-gray-400">{{ post_type.count }} Posts</span>
</div>
<div class="h-2 bg-gray-600 rounded-full overflow-hidden">
<div class="h-full bg-brand-highlight"
style="width: {{ (post_type.count / total_posts * 100) if total_posts > 0 else 0 }}%"></div>
</div>
</div>
</div>
{% endfor %}
{% if uncategorized_count > 0 %}
<div class="flex items-center gap-4">
<div class="flex-1">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-400">Nicht kategorisiert</span>
<span class="text-gray-400">{{ uncategorized_count }} Posts</span>
</div>
<div class="h-2 bg-gray-600 rounded-full overflow-hidden">
<div class="h-full bg-gray-500"
style="width: {{ (uncategorized_count / total_posts * 100) if total_posts > 0 else 0 }}%"></div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Manual Review Section -->
{% if uncategorized_posts and uncategorized_posts|length > 0 %}
<div class="mb-8">
<h3 class="text-lg font-medium text-white mb-4">Manuelle Nachkategorisierung</h3>
<p class="text-sm text-gray-400 mb-4">Diese Posts konnten nicht automatisch kategorisiert werden:</p>
<div class="space-y-4" id="manual-review-list">
{% for post in uncategorized_posts[:5] %}
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4">
<p class="text-sm text-gray-300 mb-3 line-clamp-3">{{ post.post_text[:300] }}{% if post.post_text|length > 300 %}...{% endif %}</p>
<div class="flex flex-wrap gap-2">
{% for pt in post_types %}
<button type="button"
class="text-xs px-3 py-1 rounded-full border border-gray-500 text-gray-300 hover:border-brand-highlight hover:text-brand-highlight transition-colors"
onclick="categorizePost('{{ post.id }}', '{{ pt.id }}')">
{{ pt.name }}
</button>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Navigation -->
<form method="POST" action="/onboarding/categorize">
<div class="flex justify-between pt-6 border-t border-gray-600">
<a href="/onboarding/post-types" class="btn-secondary font-medium py-3 px-6 rounded-lg transition-colors">
Zurück
</a>
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors"
{% if not classification_complete and classified_count < 3 %}disabled{% endif %}>
Weiter
</button>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
const userId = "{{ user_id }}";
{% if not classification_complete %}
document.getElementById('start-classification-btn').addEventListener('click', async function() {
const btn = this;
btn.disabled = true;
btn.innerHTML = `
<svg class="w-4 h-4 animate-spin" 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 12h4z"></path>
</svg>
Kategorisiere...
`;
try {
const response = await fetch('/api/onboarding/classify-posts', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user_id: userId})
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
btn.disabled = false;
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="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 2"/>
</svg>
Automatisch kategorisieren
`;
}
} catch (e) {
alert('Fehler bei der Kategorisierung');
btn.disabled = false;
}
});
{% endif %}
async function categorizePost(postId, postTypeId) {
try {
const response = await fetch('/api/onboarding/categorize-post', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({post_id: postId, post_type_id: postTypeId})
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (e) {
alert('Fehler bei der Kategorisierung');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,77 @@
{% extends "onboarding/base.html" %}
{% block title %}Unternehmensdaten{% endblock %}
{% block content %}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Unternehmensdaten</h1>
<p class="text-gray-400">Erzähl uns mehr über dein Unternehmen.</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 %}
<form method="POST" action="/onboarding/company" class="space-y-6">
<!-- Company Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-300 mb-1">
Unternehmensname *
</label>
<input type="text" id="name" name="name" required
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Dein Unternehmen GmbH"
value="{{ company.name if company else '' }}">
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-300 mb-1">
Beschreibung
</label>
<textarea id="description" name="description" rows="3"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Was macht dein Unternehmen?">{{ company.description if company else '' }}</textarea>
</div>
<!-- Website & Industry -->
<div class="grid md:grid-cols-2 gap-4">
<div>
<label for="website" class="block text-sm font-medium text-gray-300 mb-1">
Website
</label>
<input type="url" id="website" name="website"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="https://dein-unternehmen.de"
value="{{ company.website if company else '' }}">
</div>
<div>
<label for="industry" class="block text-sm font-medium text-gray-300 mb-1">
Branche
</label>
<select id="industry" name="industry"
class="w-full input-bg border rounded-lg px-4 py-3 text-white">
<option value="">Bitte wählen...</option>
<option value="technology" {% if company and company.industry == 'technology' %}selected{% endif %}>Technologie</option>
<option value="finance" {% if company and company.industry == 'finance' %}selected{% endif %}>Finanzen</option>
<option value="healthcare" {% if company and company.industry == 'healthcare' %}selected{% endif %}>Gesundheitswesen</option>
<option value="education" {% if company and company.industry == 'education' %}selected{% endif %}>Bildung</option>
<option value="retail" {% if company and company.industry == 'retail' %}selected{% endif %}>Einzelhandel</option>
<option value="manufacturing" {% if company and company.industry == 'manufacturing' %}selected{% endif %}>Produktion</option>
<option value="consulting" {% if company and company.industry == 'consulting' %}selected{% endif %}>Beratung</option>
<option value="marketing" {% if company and company.industry == 'marketing' %}selected{% endif %}>Marketing & Werbung</option>
<option value="real_estate" {% if company and company.industry == 'real_estate' %}selected{% endif %}>Immobilien</option>
<option value="other" {% if company and company.industry == 'other' %}selected{% endif %}>Sonstiges</option>
</select>
</div>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
Weiter zur Strategie
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends "onboarding/base.html" %}
{% block title %}Setup abgeschlossen{% endblock %}
{% block content %}
<div class="text-center">
<!-- Success Icon -->
<div class="w-20 h-20 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-green-500" 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>
</div>
<h1 class="text-2xl font-bold text-white mb-2">Setup abgeschlossen!</h1>
<p class="text-gray-400 mb-8">Dein Profil wurde erfolgreich eingerichtet. Du kannst jetzt mit dem Erstellen von Posts beginnen.</p>
<!-- Summary -->
<div class="bg-brand-bg border border-gray-600 rounded-lg p-6 mb-8 text-left">
<h3 class="text-lg font-medium text-white mb-4">Zusammenfassung</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-400">LinkedIn-Profil</span>
<span class="text-white">{{ profile.linkedin_url }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Posts analysiert</span>
<span class="text-white">{{ posts_count }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Post-Typen definiert</span>
<span class="text-white">{{ post_types_count }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Profil-Analyse</span>
{% if profile_analysis %}
<span class="text-green-400">Abgeschlossen</span>
{% elif analysis_started %}
<span class="text-brand-highlight animate-pulse">Läuft im Hintergrund...</span>
{% else %}
<span class="text-gray-500">Ausstehend</span>
{% endif %}
</div>
</div>
</div>
{% if analysis_started %}
<!-- Analysis Running Info -->
<div class="bg-brand-highlight/10 border border-brand-highlight/30 rounded-lg p-4 mb-8 text-left">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-brand-highlight mt-0.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>
<div>
<h4 class="font-medium text-brand-highlight">Analyse läuft im Hintergrund</h4>
<p class="text-sm text-gray-400 mt-1">
Wir analysieren jetzt dein Profil, kategorisieren deine Posts und analysieren deine Post-Typen.
Du kannst das Dashboard bereits nutzen - die Fortschrittsanzeige siehst du unten rechts.
</p>
</div>
</div>
</div>
{% endif %}
<!-- Next Steps -->
<div class="grid md:grid-cols-3 gap-4 mb-8">
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-3 mx-auto">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<h4 class="font-medium text-white mb-1">Topics recherchieren</h4>
<p class="text-xs text-gray-400">Finde relevante Themen für deine nächsten Posts</p>
</div>
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-3 mx-auto">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</div>
<h4 class="font-medium text-white mb-1">Post erstellen</h4>
<p class="text-xs text-gray-400">Schreibe deinen ersten KI-unterstützten Post</p>
</div>
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-3 mx-auto">
<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="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>
</div>
<h4 class="font-medium text-white mb-1">Einstellungen</h4>
<p class="text-xs text-gray-400">Passe dein Profil weiter an</p>
</div>
</div>
<!-- CTA -->
<a href="/" class="inline-block btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
Zum Dashboard
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,229 @@
{% extends "onboarding/base.html" %}
{% block title %}Post-Typen{% endblock %}
{% block content %}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Post-Typen definieren</h1>
<p class="text-gray-400">Definiere die verschiedenen Arten von Posts, die du schreibst.</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 %}
<!-- Predefined Post Types -->
<div class="mb-8">
<h3 class="text-lg font-medium text-white mb-4">Vordefinierte Post-Typen</h3>
<p class="text-sm text-gray-400 mb-4">Wähle die Post-Typen aus, die zu deinem Content passen:</p>
<div class="grid md:grid-cols-2 gap-4" id="predefined-types">
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
<input type="checkbox" name="predefined_type" value="thought_leadership" class="mt-1">
<div>
<span class="text-white font-medium">Thought Leadership</span>
<p class="text-sm text-gray-400">Brancheninsights, Meinungen, Trends</p>
</div>
</label>
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
<input type="checkbox" name="predefined_type" value="personal_story" class="mt-1">
<div>
<span class="text-white font-medium">Personal Story</span>
<p class="text-sm text-gray-400">Persönliche Erfahrungen, Learnings</p>
</div>
</label>
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
<input type="checkbox" name="predefined_type" value="how_to" class="mt-1">
<div>
<span class="text-white font-medium">How-To / Tutorial</span>
<p class="text-sm text-gray-400">Anleitungen, Tipps, Best Practices</p>
</div>
</label>
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
<input type="checkbox" name="predefined_type" value="news_commentary" class="mt-1">
<div>
<span class="text-white font-medium">News & Kommentar</span>
<p class="text-sm text-gray-400">Aktuelle Nachrichten mit Einordnung</p>
</div>
</label>
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
<input type="checkbox" name="predefined_type" value="case_study" class="mt-1">
<div>
<span class="text-white font-medium">Case Study</span>
<p class="text-sm text-gray-400">Erfolgsgeschichten, Projektberichte</p>
</div>
</label>
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
<input type="checkbox" name="predefined_type" value="engagement" class="mt-1">
<div>
<span class="text-white font-medium">Engagement Post</span>
<p class="text-sm text-gray-400">Fragen, Umfragen, Diskussionen</p>
</div>
</label>
</div>
</div>
<!-- Custom Post Types -->
<div class="mb-8">
<h3 class="text-lg font-medium text-white mb-4">Eigene Post-Typen</h3>
<div id="custom-types-list" class="space-y-3 mb-4">
<!-- Custom types will be added here -->
</div>
<button type="button" id="add-custom-type-btn"
class="btn-secondary py-2 px-4 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="M12 4v16m8-8H4"/>
</svg>
Eigenen Typ hinzufügen
</button>
</div>
<!-- Navigation -->
<form method="POST" action="/onboarding/post-types" id="post-types-form">
<input type="hidden" name="post_types_json" id="post-types-json">
<div class="flex justify-between pt-6 border-t border-gray-600">
<a href="/onboarding/posts" class="btn-secondary font-medium py-3 px-6 rounded-lg transition-colors">
Zurück
</a>
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
Weiter
</button>
</div>
</form>
<!-- Custom Type Modal -->
<div id="custom-type-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card-bg rounded-xl p-6 w-full max-w-md mx-4">
<h3 class="text-lg font-medium text-white mb-4">Eigenen Post-Typ erstellen</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Name *</label>
<input type="text" id="custom-type-name"
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="z.B. Produktvorstellung">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Beschreibung</label>
<textarea id="custom-type-description" rows="2"
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Kurze Beschreibung..."></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Keywords (kommagetrennt)</label>
<input type="text" id="custom-type-keywords"
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="produkt, launch, neu">
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button type="button" onclick="closeCustomTypeModal()"
class="btn-secondary py-2 px-4 rounded-lg">Abbrechen</button>
<button type="button" onclick="addCustomType()"
class="btn-primary py-2 px-4 rounded-lg">Hinzufügen</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const customTypes = [];
// Predefined types mapping
const predefinedTypes = {
'thought_leadership': {name: 'Thought Leadership', description: 'Brancheninsights, Meinungen, Trends', keywords: ['insight', 'trend', 'meinung']},
'personal_story': {name: 'Personal Story', description: 'Persönliche Erfahrungen, Learnings', keywords: ['story', 'erfahrung', 'learning']},
'how_to': {name: 'How-To / Tutorial', description: 'Anleitungen, Tipps, Best Practices', keywords: ['howto', 'tipps', 'anleitung']},
'news_commentary': {name: 'News & Kommentar', description: 'Aktuelle Nachrichten mit Einordnung', keywords: ['news', 'aktuell', 'kommentar']},
'case_study': {name: 'Case Study', description: 'Erfolgsgeschichten, Projektberichte', keywords: ['case', 'projekt', 'erfolg']},
'engagement': {name: 'Engagement Post', description: 'Fragen, Umfragen, Diskussionen', keywords: ['frage', 'umfrage', 'diskussion']}
};
document.getElementById('add-custom-type-btn').addEventListener('click', function() {
document.getElementById('custom-type-modal').classList.remove('hidden');
});
function closeCustomTypeModal() {
document.getElementById('custom-type-modal').classList.add('hidden');
document.getElementById('custom-type-name').value = '';
document.getElementById('custom-type-description').value = '';
document.getElementById('custom-type-keywords').value = '';
}
function addCustomType() {
const name = document.getElementById('custom-type-name').value.trim();
const description = document.getElementById('custom-type-description').value.trim();
const keywords = document.getElementById('custom-type-keywords').value.split(',').map(k => k.trim()).filter(k => k);
if (!name) {
alert('Name ist erforderlich');
return;
}
customTypes.push({name, description, keywords, custom: true});
renderCustomTypes();
closeCustomTypeModal();
}
function removeCustomType(index) {
customTypes.splice(index, 1);
renderCustomTypes();
}
function renderCustomTypes() {
const container = document.getElementById('custom-types-list');
container.innerHTML = customTypes.map((type, index) => `
<div class="flex items-center justify-between p-3 bg-brand-bg border border-gray-600 rounded-lg">
<div>
<span class="text-white font-medium">${type.name}</span>
${type.description ? `<p class="text-sm text-gray-400">${type.description}</p>` : ''}
</div>
<button type="button" onclick="removeCustomType(${index})" class="text-gray-500 hover:text-red-400">
<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>
`).join('');
}
// Form submission
document.getElementById('post-types-form').addEventListener('submit', function(e) {
// Collect selected predefined types
const selected = Array.from(document.querySelectorAll('input[name="predefined_type"]:checked'))
.map(cb => {
const def = predefinedTypes[cb.value];
return {
name: def.name,
description: def.description,
identifying_keywords: def.keywords,
custom: false
};
});
// Add custom types
const allTypes = [...selected, ...customTypes.map(t => ({
name: t.name,
description: t.description,
identifying_keywords: t.keywords,
custom: true
}))];
document.getElementById('post-types-json').value = JSON.stringify(allTypes);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,226 @@
{% extends "onboarding/base.html" %}
{% block title %}Posts analysieren{% endblock %}
{% block content %}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Posts analysieren</h1>
<p class="text-gray-400">Wir analysieren die bisherigen LinkedIn-Posts deines Kunden, um den Schreibstil zu lernen.</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 %}
<!-- Scraping Status -->
<div id="scraping-section" class="mb-8">
<div class="card-bg border border-gray-600 rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-medium text-white">LinkedIn Posts von {{ profile.display_name }}</h3>
<p class="text-sm text-gray-400">Profil: {{ profile.linkedin_url }}</p>
</div>
<div id="post-count" class="text-2xl font-bold text-brand-highlight">
<span id="post-count-number">{{ scraped_posts_count }}</span> Posts
</div>
</div>
<!-- Scraping in Progress Notice -->
<div id="scraping-progress" class="{% if not scraping_in_progress %}hidden{% endif %} bg-brand-highlight/10 border border-brand-highlight/30 rounded-lg p-4 mb-4">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-brand-highlight 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>
<div>
<p class="text-brand-highlight font-medium">Posts werden im Hintergrund geladen...</p>
<p class="text-sm text-gray-400">Du kannst fortfahren sobald genug Posts vorhanden sind.</p>
</div>
</div>
</div>
{% if scraped_posts_count < 10 %}
<div id="low-posts-warning" class="bg-yellow-900/30 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-500 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">Zu wenige Posts gefunden</p>
<p class="text-yellow-300/70 text-sm">Für eine gute Stilanalyse brauchen wir mindestens 10 Posts. Du kannst entweder manuell Beispiel-Posts hinzufügen oder warten bis das Scraping fertig ist.</p>
</div>
</div>
</div>
{% endif %}
<button id="rescrape-btn" class="btn-secondary py-2 px-4 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="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>
<span>Posts neu laden</span>
</button>
</div>
</div>
<!-- Manual Posts Section -->
<div id="manual-posts-section" class="{% if scraped_posts_count >= 10 %}hidden{% endif %} mb-8">
<h3 class="text-lg font-medium text-white mb-4">Beispiel-Posts manuell hinzufügen</h3>
<form id="manual-post-form" class="space-y-4">
<div>
<textarea id="manual-post-text" rows="6"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Füge hier einen LinkedIn-Post deines Kunden ein..."></textarea>
</div>
<button type="submit" class="btn-secondary py-2 px-4 rounded-lg transition-colors">
Post hinzufügen
</button>
</form>
<!-- Added Manual Posts -->
<div id="manual-posts-list" class="mt-4 space-y-2">
{% for post in example_posts %}
<div class="bg-brand-bg border border-gray-600 rounded-lg p-3 flex justify-between items-start">
<p class="text-sm text-gray-300 line-clamp-2">{{ post.post_text[:200] }}{% if post.post_text|length > 200 %}...{% endif %}</p>
<button class="text-gray-500 hover:text-red-400 ml-2" onclick="removeManualPost('{{ post.id }}', this.parentElement)">
<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>
{% endfor %}
</div>
</div>
<!-- Navigation -->
<div class="flex justify-between pt-8 border-t border-gray-600">
<a href="/onboarding/profile" class="text-gray-400 hover:text-white transition-colors flex items-center gap-2">
<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="M11 17l-5-5m0 0l5-5m-5 5h12"/>
</svg>
Zurück
</a>
<form method="POST" action="/onboarding/posts">
<button type="submit" id="continue-btn" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors flex items-center gap-2"
{% if scraped_posts_count < 5 %}disabled{% endif %}>
Weiter
<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="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</button>
</form>
</div>
<script>
async function removeManualPost(postId, element) {
try {
const response = await fetch(`/api/onboarding/remove-manual-post/${postId}`, {method: 'DELETE'});
if (response.ok) {
if (element) {
element.remove();
} else {
// Find and remove the element by post ID
const btn = document.querySelector(`[onclick*="${postId}"]`);
if (btn) btn.closest('.bg-brand-bg').remove();
}
}
} catch (e) {
console.error('Error removing manual post:', e);
}
}
document.addEventListener('DOMContentLoaded', function() {
const postCountEl = document.getElementById('post-count-number');
const progressEl = document.getElementById('scraping-progress');
const warningEl = document.getElementById('low-posts-warning');
const manualSection = document.getElementById('manual-posts-section');
const continueBtn = document.getElementById('continue-btn');
const rescrapeBtn = document.getElementById('rescrape-btn');
// Poll for post count updates
let pollInterval = setInterval(async () => {
try {
const response = await fetch('/api/posts-count');
const data = await response.json();
postCountEl.textContent = data.count;
// Update UI based on count
if (data.count >= 10) {
if (warningEl) warningEl.classList.add('hidden');
if (manualSection) manualSection.classList.add('hidden');
}
if (data.count >= 5) {
continueBtn.disabled = false;
}
// Check if scraping is still in progress
if (!data.scraping_active) {
progressEl.classList.add('hidden');
}
} catch (e) {
console.error('Error polling post count:', e);
}
}, 3000);
// Manual post form
const form = document.getElementById('manual-post-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const textarea = document.getElementById('manual-post-text');
const text = textarea.value.trim();
if (!text) return;
try {
const response = await fetch('/api/onboarding/add-manual-post', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({post_text: text})
});
if (response.ok) {
const data = await response.json();
textarea.value = '';
// Add to list
const list = document.getElementById('manual-posts-list');
const div = document.createElement('div');
div.className = 'bg-brand-bg border border-gray-600 rounded-lg p-3 flex justify-between items-start';
div.innerHTML = `
<p class="text-sm text-gray-300 line-clamp-2">${text.substring(0, 200)}${text.length > 200 ? '...' : ''}</p>
<button class="text-gray-500 hover:text-red-400 ml-2" onclick="removeManualPost('${data.id}', this.parentElement)">
<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>
`;
list.appendChild(div);
}
} catch (e) {
console.error('Error adding manual post:', e);
}
});
// Rescrape button
rescrapeBtn.addEventListener('click', async () => {
try {
const response = await fetch('/api/onboarding/rescrape', {method: 'POST'});
if (response.ok) {
progressEl.classList.remove('hidden');
showToast('Scraping gestartet...');
}
} catch (e) {
console.error('Error starting rescrape:', e);
}
});
// Cleanup on leave
window.addEventListener('beforeunload', () => {
clearInterval(pollInterval);
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,130 @@
{% extends "onboarding/base.html" %}
{% block title %}Profil einrichten{% endblock %}
{% block content %}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Profil einrichten</h1>
<p class="text-gray-400">Richte dein Ghostwriter-Profil ein und gib die Daten deines Kunden an.</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 %}
<form method="POST" action="/onboarding/profile" class="space-y-8">
<!-- Section: Ghostwriter (You) -->
<div class="bg-brand-bg border border-gray-600 rounded-xl 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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
Deine Daten (Ghostwriter)
</h2>
<p class="text-gray-400 text-sm mb-4">Diese Informationen werden in deinem Dashboard angezeigt.</p>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label for="ghostwriter_name" class="block text-sm font-medium text-gray-300 mb-1">
Dein Name *
</label>
<input type="text" id="ghostwriter_name" name="ghostwriter_name" required
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Max Mustermann"
value="{{ prefill.ghostwriter_name or session.linkedin_name or '' }}">
</div>
<div>
<label for="creator_email" class="block text-sm font-medium text-gray-300 mb-1">
Deine E-Mail
</label>
<input type="email" id="creator_email" name="creator_email"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="ghostwriter@email.de"
value="{{ prefill.creator_email or session.email or '' }}">
<p class="text-xs text-gray-500 mt-1">Du wirst über Entscheidungen benachrichtigt</p>
</div>
</div>
</div>
<!-- Section: Client/Customer -->
<div class="bg-brand-bg border border-gray-600 rounded-xl 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="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 0z"/>
</svg>
Kunden-Daten (für wen du schreibst)
</h2>
<p class="text-gray-400 text-sm mb-4">Die Person/Marke, für die du LinkedIn-Posts erstellst.</p>
<div class="space-y-4">
<div class="grid md:grid-cols-2 gap-4">
<div>
<label for="customer_name" class="block text-sm font-medium text-gray-300 mb-1">
Kunden-Name *
</label>
<input type="text" id="customer_name" name="customer_name" required
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Lisa Kundin"
value="{{ prefill.customer_name or '' }}">
</div>
<div>
<label for="customer_email" class="block text-sm font-medium text-gray-300 mb-1">
Kunden E-Mail
</label>
<input type="email" id="customer_email" name="customer_email"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="kunde@email.de"
value="{{ prefill.customer_email or '' }}">
<p class="text-xs text-gray-500 mt-1">Genehmigt Posts per E-Mail (optional)</p>
</div>
</div>
<div>
<label for="linkedin_url" class="block text-sm font-medium text-gray-300 mb-1">
LinkedIn-Profil des Kunden *
</label>
<input type="url" id="linkedin_url" name="linkedin_url" required
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="https://linkedin.com/in/kunde-name"
value="{{ prefill.linkedin_url or '' }}">
<p class="text-xs text-gray-500 mt-1">Wir analysieren die bisherigen Posts, um den Schreibstil zu lernen</p>
</div>
<div>
<label for="company_name" class="block text-sm font-medium text-gray-300 mb-1">
Firma des Kunden (optional)
</label>
<input type="text" id="company_name" name="company_name"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Kunden GmbH"
value="{{ prefill.company_name or '' }}">
</div>
</div>
</div>
<!-- Section: Writing Style Notes -->
<div class="bg-brand-bg border border-gray-600 rounded-xl 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
Schreibstil-Notizen (optional)
</h2>
<textarea id="writing_style_notes" name="writing_style_notes" rows="4"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Besondere Hinweise zum Schreibstil des Kunden, z.B. 'Duzt immer', 'Nutzt oft Emojis', 'Formeller Ton', etc.">{{ prefill.writing_style_notes or '' }}</textarea>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors flex items-center gap-2">
Weiter
<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="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,93 @@
{% extends "onboarding/base.html" %}
{% block title %}Profil einrichten{% endblock %}
{% block content %}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Profil einrichten</h1>
<p class="text-gray-400">Richte dein LinkedIn-Profil ein, um personalisierte Posts zu erstellen.</p>
{% if session.company_name %}
<p class="text-brand-highlight text-sm mt-2">Du wirst als Mitarbeiter von {{ session.company_name }} registriert.</p>
{% endif %}
</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 %}
<form method="POST" action="/onboarding/profile" class="space-y-8">
<!-- Hidden field to indicate employee flow -->
<input type="hidden" name="is_employee" value="true">
<!-- Section: Your Profile -->
<div class="bg-brand-bg border border-gray-600 rounded-xl 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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
Deine Daten
</h2>
<p class="text-gray-400 text-sm mb-4">Diese Informationen werden in deinem Dashboard angezeigt.</p>
<div class="space-y-4">
<div class="grid md:grid-cols-2 gap-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-300 mb-1">
Dein Name *
</label>
<input type="text" id="name" name="name" required
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Max Mustermann"
value="{{ prefill.name or session.linkedin_name or '' }}">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-300 mb-1">
Deine E-Mail
</label>
<input type="email" id="email" name="email"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="deine@email.de"
value="{{ prefill.email or session.email or '' }}">
<p class="text-xs text-gray-500 mt-1">Du wirst benachrichtigt, wenn Posts bereit sind</p>
</div>
</div>
<div>
<label for="linkedin_url" class="block text-sm font-medium text-gray-300 mb-1">
Dein LinkedIn-Profil *
</label>
<input type="url" id="linkedin_url" name="linkedin_url" required
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="https://linkedin.com/in/dein-name"
value="{{ prefill.linkedin_url or '' }}">
<p class="text-xs text-gray-500 mt-1">Wir analysieren deine bisherigen Posts, um deinen Schreibstil zu lernen</p>
</div>
</div>
</div>
<!-- Section: Writing Style Notes -->
<div class="bg-brand-bg border border-gray-600 rounded-xl 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
Schreibstil-Notizen (optional)
</h2>
<textarea id="writing_style_notes" name="writing_style_notes" rows="4"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Besondere Hinweise zu deinem Schreibstil, z.B. 'Ich duze immer', 'Nutze oft Emojis', 'Formeller Ton', etc.">{{ prefill.writing_style_notes or '' }}</textarea>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors flex items-center gap-2">
Weiter
<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="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,277 @@
{% extends "onboarding/base.html" %}
{% block title %}Unternehmensstrategie{% endblock %}
{% block content %}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Unternehmensstrategie</h1>
<p class="text-gray-400">Definiere die Content-Strategie für dein Unternehmen. Diese Richtlinien werden bei jedem Post berücksichtigt.</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 %}
<form method="POST" action="/onboarding/strategy" class="space-y-8">
<!-- Mission & Vision -->
<div class="space-y-4">
<h3 class="text-lg font-medium text-white">Mission & Vision</h3>
<div>
<label for="mission" class="block text-sm font-medium text-gray-300 mb-1">
Mission
</label>
<textarea id="mission" name="mission" rows="2"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Was ist der Zweck deines Unternehmens?">{{ strategy.mission if strategy else '' }}</textarea>
</div>
<div>
<label for="vision" class="block text-sm font-medium text-gray-300 mb-1">
Vision
</label>
<textarea id="vision" name="vision" rows="2"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Wo soll dein Unternehmen in 5-10 Jahren stehen?">{{ strategy.vision if strategy else '' }}</textarea>
</div>
</div>
<!-- Brand Voice -->
<div class="space-y-4">
<h3 class="text-lg font-medium text-white">Brand Voice & Tonalität</h3>
<div>
<label for="brand_voice" class="block text-sm font-medium text-gray-300 mb-1">
Brand Voice
</label>
<textarea id="brand_voice" name="brand_voice" rows="2"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Wie soll dein Unternehmen klingen? z.B. 'professionell aber nahbar', 'innovativ und zukunftsorientiert'">{{ strategy.brand_voice if strategy else '' }}</textarea>
</div>
<div>
<label for="tone_guidelines" class="block text-sm font-medium text-gray-300 mb-1">
Tonalitäts-Richtlinien
</label>
<textarea id="tone_guidelines" name="tone_guidelines" rows="3"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Spezifische Anweisungen zur Tonalität, z.B. 'Wir duzen unsere Zielgruppe', 'Wir nutzen keine Anglizismen'">{{ strategy.tone_guidelines if strategy else '' }}</textarea>
</div>
</div>
<!-- Content Pillars -->
<div class="space-y-4">
<h3 class="text-lg font-medium text-white">Content Pillars</h3>
<p class="text-sm text-gray-400">Die Hauptthemen, über die dein Unternehmen kommuniziert (max. 5).</p>
<div id="content-pillars" class="space-y-2">
{% if strategy and strategy.content_pillars %}
{% for pillar in strategy.content_pillars %}
<div class="flex gap-2 pillar-row">
<input type="text" name="content_pillar" value="{{ pillar }}"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="z.B. 'Digitale Transformation'">
<button type="button" onclick="removePillar(this)"
class="text-gray-500 hover:text-red-400 px-2">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endfor %}
{% else %}
<div class="flex gap-2 pillar-row">
<input type="text" name="content_pillar"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="z.B. 'Digitale Transformation'">
<button type="button" onclick="removePillar(this)"
class="text-gray-500 hover:text-red-400 px-2">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endif %}
</div>
<button type="button" onclick="addPillar()"
class="text-sm text-brand-highlight hover:underline flex items-center gap-1">
<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="M12 4v16m8-8H4"/>
</svg>
Weiteren Pillar hinzufügen
</button>
</div>
<!-- Target Audience -->
<div>
<label for="target_audience" class="block text-sm font-medium text-gray-300 mb-1">
Zielgruppe
</label>
<textarea id="target_audience" name="target_audience" rows="2"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Wer ist eure Zielgruppe auf LinkedIn? z.B. 'B2B Entscheider in der DACH-Region, CEOs und CTOs von mittelstaendischen Unternehmen'">{{ strategy.target_audience if strategy else '' }}</textarea>
</div>
<!-- Do's and Don'ts -->
<div class="grid md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">
Do's (empfohlen)
</label>
<div id="dos-list" class="space-y-2 mb-2">
{% if strategy and strategy.dos %}
{% for item in strategy.dos %}
<div class="flex gap-2 do-row">
<input type="text" name="do_item" value="{{ item }}"
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
placeholder="z.B. 'Aktuelle Studien zitieren'">
<button type="button" onclick="removeItem(this)"
class="text-gray-500 hover:text-red-400">
<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>
{% endfor %}
{% else %}
<div class="flex gap-2 do-row">
<input type="text" name="do_item"
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
placeholder="z.B. 'Aktuelle Studien zitieren'">
<button type="button" onclick="removeItem(this)"
class="text-gray-500 hover:text-red-400">
<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>
{% endif %}
</div>
<button type="button" onclick="addDo()"
class="text-xs text-brand-highlight hover:underline">+ Hinzufügen</button>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">
Don'ts (vermeiden)
</label>
<div id="donts-list" class="space-y-2 mb-2">
{% if strategy and strategy.donts %}
{% for item in strategy.donts %}
<div class="flex gap-2 dont-row">
<input type="text" name="dont_item" value="{{ item }}"
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
placeholder="z.B. 'Keine politischen Themen'">
<button type="button" onclick="removeItem(this)"
class="text-gray-500 hover:text-red-400">
<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>
{% endfor %}
{% else %}
<div class="flex gap-2 dont-row">
<input type="text" name="dont_item"
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
placeholder="z.B. 'Keine politischen Themen'">
<button type="button" onclick="removeItem(this)"
class="text-gray-500 hover:text-red-400">
<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>
{% endif %}
</div>
<button type="button" onclick="addDont()"
class="text-xs text-brand-highlight hover:underline">+ Hinzufügen</button>
</div>
</div>
<div class="flex justify-between pt-6 border-t border-gray-600">
<a href="/onboarding/company" class="btn-secondary font-medium py-3 px-6 rounded-lg transition-colors">
Zurück
</a>
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
Strategie speichern
</button>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
function addPillar() {
const container = document.getElementById('content-pillars');
if (container.querySelectorAll('.pillar-row').length >= 5) {
alert('Maximal 5 Content Pillars erlaubt');
return;
}
const row = document.createElement('div');
row.className = 'flex gap-2 pillar-row';
row.innerHTML = `
<input type="text" name="content_pillar"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
placeholder="z.B. 'Digitale Transformation'">
<button type="button" onclick="removePillar(this)"
class="text-gray-500 hover:text-red-400 px-2">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
`;
container.appendChild(row);
}
function removePillar(btn) {
const container = document.getElementById('content-pillars');
if (container.querySelectorAll('.pillar-row').length > 1) {
btn.closest('.pillar-row').remove();
}
}
function addDo() {
const container = document.getElementById('dos-list');
const row = document.createElement('div');
row.className = 'flex gap-2 do-row';
row.innerHTML = `
<input type="text" name="do_item"
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
placeholder="z.B. 'Aktuelle Studien zitieren'">
<button type="button" onclick="removeItem(this)"
class="text-gray-500 hover:text-red-400">
<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>
`;
container.appendChild(row);
}
function addDont() {
const container = document.getElementById('donts-list');
const row = document.createElement('div');
row.className = 'flex gap-2 dont-row';
row.innerHTML = `
<input type="text" name="dont_item"
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
placeholder="z.B. 'Keine politischen Themen'">
<button type="button" onclick="removeItem(this)"
class="text-gray-500 hover:text-red-400">
<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>
`;
container.appendChild(row);
}
function removeItem(btn) {
btn.closest('div').remove();
}
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,352 @@
{% extends "base.html" %}
{% block title %}Post-Typen{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Post-Typen</h1>
<p class="text-gray-400 mt-1">Verwalte und kategorisiere deine LinkedIn-Posts</p>
</div>
<button onclick="runAnalysis()" id="analyze-btn" class="btn-primary px-5 py-2.5 rounded-lg font-medium flex items-center gap-2">
<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>
</div>
<!-- Filter & Search Bar -->
<div class="card-bg border rounded-xl p-4 mb-6">
<div class="flex flex-col md:flex-row gap-4">
<!-- Search -->
<div class="flex-1">
<div class="relative">
<svg class="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text" id="search-input" placeholder="Posts durchsuchen..."
class="w-full input-bg border rounded-lg pl-10 pr-4 py-2.5 text-white"
oninput="filterPosts()">
</div>
</div>
<!-- Post Type Filter -->
<div class="md:w-64">
<select id="type-filter" class="w-full input-bg border rounded-lg px-4 py-2.5 text-white" onchange="filterPosts()">
<option value="all">Alle Post-Typen</option>
<option value="uncategorized">Nicht kategorisiert</option>
{% for pt in post_types %}
<option value="{{ pt.id }}">{{ pt.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Stats Row -->
<div class="flex flex-wrap gap-4 mt-4 pt-4 border-t border-gray-600">
<div class="text-sm">
<span class="text-gray-400">Gesamt:</span>
<span class="text-white font-medium ml-1">{{ (uncategorized_posts|length) + (categorized_posts|length) }} Posts</span>
</div>
<div class="text-sm">
<span class="text-gray-400">Kategorisiert:</span>
<span class="text-green-400 font-medium ml-1">{{ categorized_posts|length }}</span>
</div>
<div class="text-sm">
<span class="text-gray-400">Nicht kategorisiert:</span>
<span class="text-yellow-400 font-medium ml-1">{{ uncategorized_posts|length }}</span>
</div>
</div>
</div>
<!-- Posts List -->
<div class="space-y-3" id="posts-list">
{% for post in uncategorized_posts %}
<div class="post-item card-bg border rounded-xl overflow-hidden"
data-post-id="{{ post.id }}"
data-type-id=""
data-text="{{ post.post_text|lower }}">
<div class="p-4">
<div class="flex items-start justify-between gap-4">
<!-- Post Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<span class="px-2 py-0.5 text-xs rounded-full bg-yellow-900/50 text-yellow-300">
Nicht kategorisiert
</span>
{% if post.post_date %}
<span class="text-xs text-gray-500">{{ post.post_date.strftime('%d.%m.%Y') if post.post_date else '' }}</span>
{% endif %}
</div>
<p class="text-gray-300 text-sm post-text line-clamp-3">{{ post.post_text[:300] }}{% if post.post_text|length > 300 %}...{% endif %}</p>
</div>
<!-- Post Type Selector -->
<div class="flex-shrink-0 w-48">
<select onchange="categorizePost('{{ post.id }}', this.value)"
class="w-full input-bg border rounded-lg px-3 py-2 text-sm text-white">
<option value="">Typ wählen...</option>
{% for pt in post_types %}
<option value="{{ pt.id }}">{{ pt.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Expand Button -->
<button onclick="toggleExpand(this)" class="text-xs text-gray-500 hover:text-gray-300 mt-2 flex items-center gap-1">
<svg class="w-4 h-4 expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
<span class="expand-text">Mehr anzeigen</span>
</button>
</div>
<!-- Full Content (hidden by default) -->
<div class="full-content hidden border-t border-gray-600 p-4 bg-brand-bg/30">
<p class="text-gray-300 text-sm whitespace-pre-wrap">{{ post.post_text }}</p>
{% if post.likes or post.comments %}
<div class="flex gap-4 mt-3 text-xs text-gray-500">
{% if post.likes %}<span>{{ post.likes }} Likes</span>{% endif %}
{% if post.comments %}<span>{{ post.comments }} Kommentare</span>{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% for post in categorized_posts %}
<div class="post-item card-bg border rounded-xl overflow-hidden"
data-post-id="{{ post.id }}"
data-type-id="{{ post.post_type_id or '' }}"
data-text="{{ post.post_text|lower }}">
<div class="p-4">
<div class="flex items-start justify-between gap-4">
<!-- Post Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
{% set type_name = "Unbekannt" %}
{% for pt in post_types %}
{% if pt.id == post.post_type_id %}
{% set type_name = pt.name %}
{% endif %}
{% endfor %}
<span class="px-2 py-0.5 text-xs rounded-full bg-brand-highlight/20 text-brand-highlight">
{{ type_name }}
</span>
{% if post.post_date %}
<span class="text-xs text-gray-500">{{ post.post_date.strftime('%d.%m.%Y') if post.post_date else '' }}</span>
{% endif %}
</div>
<p class="text-gray-300 text-sm post-text line-clamp-3">{{ post.post_text[:300] }}{% if post.post_text|length > 300 %}...{% endif %}</p>
</div>
<!-- Post Type Selector -->
<div class="flex-shrink-0 w-48">
<select onchange="categorizePost('{{ post.id }}', this.value)"
class="w-full input-bg border rounded-lg px-3 py-2 text-sm text-white">
<option value="">Typ ändern...</option>
{% for pt in post_types %}
<option value="{{ pt.id }}" {% if pt.id == post.post_type_id %}selected{% endif %}>{{ pt.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Expand Button -->
<button onclick="toggleExpand(this)" class="text-xs text-gray-500 hover:text-gray-300 mt-2 flex items-center gap-1">
<svg class="w-4 h-4 expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
<span class="expand-text">Mehr anzeigen</span>
</button>
</div>
<!-- Full Content (hidden by default) -->
<div class="full-content hidden border-t border-gray-600 p-4 bg-brand-bg/30">
<p class="text-gray-300 text-sm whitespace-pre-wrap">{{ post.post_text }}</p>
{% if post.likes or post.comments %}
<div class="flex gap-4 mt-3 text-xs text-gray-500">
{% if post.likes %}<span>{{ post.likes }} Likes</span>{% endif %}
{% if post.comments %}<span>{{ post.comments }} Kommentare</span>{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Empty State -->
{% if not uncategorized_posts and not categorized_posts %}
<div class="card-bg border rounded-xl p-12 text-center">
<svg class="w-16 h-16 text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<h3 class="text-xl font-semibold text-white mb-2">Keine Posts gefunden</h3>
<p class="text-gray-400">Es wurden noch keine LinkedIn-Posts geladen.</p>
</div>
{% endif %}
<!-- No Results State (hidden by default) -->
<div id="no-results" class="hidden card-bg border rounded-xl p-12 text-center">
<svg class="w-16 h-16 text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<h3 class="text-xl font-semibold text-white mb-2">Keine Ergebnisse</h3>
<p class="text-gray-400">Keine Posts gefunden, die deinen Filterkriterien entsprechen.</p>
</div>
</div>
<script>
function toggleExpand(btn) {
const card = btn.closest('.post-item');
const fullContent = card.querySelector('.full-content');
const icon = btn.querySelector('.expand-icon');
const text = btn.querySelector('.expand-text');
if (fullContent.classList.contains('hidden')) {
fullContent.classList.remove('hidden');
icon.style.transform = 'rotate(180deg)';
text.textContent = 'Weniger anzeigen';
card.querySelector('.post-text').classList.remove('line-clamp-3');
} else {
fullContent.classList.add('hidden');
icon.style.transform = '';
text.textContent = 'Mehr anzeigen';
card.querySelector('.post-text').classList.add('line-clamp-3');
}
}
function filterPosts() {
const searchTerm = document.getElementById('search-input').value.toLowerCase();
const typeFilter = document.getElementById('type-filter').value;
const posts = document.querySelectorAll('.post-item');
let visibleCount = 0;
posts.forEach(post => {
const text = post.dataset.text;
const typeId = post.dataset.typeId;
const matchesSearch = !searchTerm || text.includes(searchTerm);
let matchesType = true;
if (typeFilter === 'uncategorized') {
matchesType = !typeId;
} else if (typeFilter !== 'all') {
matchesType = typeId === typeFilter;
}
if (matchesSearch && matchesType) {
post.classList.remove('hidden');
visibleCount++;
} else {
post.classList.add('hidden');
}
});
// Show/hide no results message
const noResults = document.getElementById('no-results');
if (visibleCount === 0 && posts.length > 0) {
noResults.classList.remove('hidden');
} else {
noResults.classList.add('hidden');
}
}
async function categorizePost(postId, postTypeId) {
if (!postTypeId) return;
const postEl = document.querySelector(`[data-post-id="${postId}"]`);
try {
const response = await fetch('/api/categorize-post', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({post_id: postId, post_type_id: postTypeId})
});
if (response.ok) {
// Update the badge
const badge = postEl.querySelector('.px-2.py-0\\.5');
const select = postEl.querySelector('select');
const selectedOption = select.options[select.selectedIndex];
badge.textContent = selectedOption.text;
badge.className = 'px-2 py-0.5 text-xs rounded-full bg-brand-highlight/20 text-brand-highlight';
// Update data attribute
postEl.dataset.typeId = postTypeId;
// Show success toast
if (window.showToast) {
showToast('Post kategorisiert!', 'success');
}
}
} catch (error) {
console.error('Error categorizing post:', error);
if (window.showToast) {
showToast('Fehler beim Kategorisieren', 'error');
}
}
}
async function runAnalysis() {
const btn = document.getElementById('analyze-btn');
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<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> Analysiere...';
try {
const response = await fetch('/api/run-post-type-analysis', {
method: 'POST'
});
if (response.ok) {
const data = await response.json();
if (window.showToast) {
showToast('Post-Typ-Analyse gestartet!');
}
// Listen for job completion via SSE, then reload
if (data.job_id) {
const evtSource = new EventSource('/api/job-updates');
evtSource.onmessage = function(event) {
try {
const jobData = JSON.parse(event.data);
if (jobData.id === data.job_id) {
if (jobData.status === 'completed' || jobData.status === 'failed') {
evtSource.close();
btn.disabled = false;
btn.innerHTML = originalHTML;
if (jobData.status === 'completed') {
window.location.reload();
} else {
if (window.showToast) showToast('Analyse fehlgeschlagen', 'error');
}
}
}
} catch (e) {
console.error('Error parsing SSE data:', e);
}
};
evtSource.onerror = function() {
evtSource.close();
btn.disabled = false;
btn.innerHTML = originalHTML;
};
} else {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
} else {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
} catch (error) {
console.error('Error starting analysis:', error);
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
</script>
{% endblock %}

View File

@@ -1,39 +1,175 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Meine Posts - LinkedIn Posts{% endblock %} {% block title %}Meine Posts - LinkedIn Posts{% endblock %}
{% macro render_post_card(post) %}
<div class="post-card"
draggable="true"
data-post-id="{{ post.id }}"
ondragstart="handleDragStart(event)"
ondragend="handleDragEnd(event)"
onclick="window.location.href='/posts/{{ post.id }}'">
<div class="flex items-start justify-between gap-2 mb-2">
<h4 class="post-card-title">{{ post.topic_title or 'Untitled' }}</h4>
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
{% set score = post.critic_feedback[-1].overall_score %}
<span class="score-badge flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
{{ score }}
</span>
{% endif %}
</div>
<div class="post-card-meta">
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}
</span>
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.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>
{{ post.iterations }}x
</span>
</div>
{% if post.post_content %}
<p class="post-card-preview">{{ post.post_content[:150] }}{% if post.post_content | length > 150 %}...{% endif %}</p>
{% endif %}
</div>
{% endmacro %}
{% block head %} {% block head %}
<style> <style>
.post-card { .kanban-board {
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%); display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
min-height: calc(100vh - 250px);
}
@media (max-width: 1024px) {
.kanban-board {
grid-template-columns: 1fr;
}
}
.kanban-column {
background: rgba(45, 56, 56, 0.3);
border: 1px solid rgba(61, 72, 72, 0.6); border: 1px solid rgba(61, 72, 72, 0.6);
border-radius: 1rem;
display: flex;
flex-direction: column;
min-height: 400px;
}
.kanban-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(61, 72, 72, 0.6);
display: flex;
align-items: center;
justify-content: space-between;
}
.kanban-header h3 {
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.kanban-count {
background: rgba(61, 72, 72, 0.8);
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.kanban-body {
flex: 1;
padding: 1rem;
overflow-y: auto;
min-height: 100px;
}
.kanban-body.drag-over {
background: rgba(255, 199, 0, 0.05);
border: 2px dashed rgba(255, 199, 0, 0.3);
border-radius: 0.5rem;
margin: 0.5rem;
}
.post-card {
background: linear-gradient(135deg, rgba(61, 72, 72, 0.5) 0%, rgba(45, 56, 56, 0.6) 100%);
border: 1px solid rgba(61, 72, 72, 0.8);
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 0.75rem;
cursor: grab;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.post-card:hover { .post-card:hover {
border-color: rgba(255, 199, 0, 0.3); border-color: rgba(255, 199, 0, 0.4);
transform: translateY(-1px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
} }
.score-ring { .post-card.dragging {
width: 44px; opacity: 0.5;
height: 44px; cursor: grabbing;
border-radius: 50%; }
.post-card-title {
font-weight: 500;
color: white;
margin-bottom: 0.5rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-card-meta {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 0.75rem;
font-weight: 700;
font-size: 0.75rem; font-size: 0.75rem;
color: #9ca3af;
}
.post-card-preview {
font-size: 0.8rem;
color: #9ca3af;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(61, 72, 72, 0.6);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.score-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 600;
}
.score-high { background: rgba(34, 197, 94, 0.2); color: #86efac; }
.score-medium { background: rgba(234, 179, 8, 0.2); color: #fde047; }
.score-low { background: rgba(239, 68, 68, 0.2); color: #fca5a5; }
.column-draft .kanban-header { border-left: 3px solid #f59e0b; }
.column-approved .kanban-header { border-left: 3px solid #3b82f6; }
.column-ready .kanban-header { border-left: 3px solid #22c55e; }
.empty-column {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: #6b7280;
text-align: center;
}
.empty-column svg {
width: 3rem;
height: 3rem;
margin-bottom: 0.75rem;
opacity: 0.5;
} }
.score-high { background: rgba(34, 197, 94, 0.2); border: 2px solid rgba(34, 197, 94, 0.5); color: #86efac; }
.score-medium { background: rgba(234, 179, 8, 0.2); border: 2px solid rgba(234, 179, 8, 0.5); color: #fde047; }
.score-low { background: rgba(239, 68, 68, 0.2); border: 2px solid rgba(239, 68, 68, 0.5); color: #fca5a5; }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="mb-8 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<div> <div>
<h1 class="text-3xl font-bold text-white mb-2">Meine Posts</h1> <h1 class="text-2xl font-bold text-white mb-1">Meine Posts</h1>
<p class="text-gray-400">{{ total_posts }} generierte Posts</p> <p class="text-gray-400 text-sm">Ziehe Posts zwischen den Spalten um den Status zu ändern</p>
</div> </div>
<a href="/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2"> <a href="/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
<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 6v6m0 0v6m0-6h6m-6 0H6"/></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="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
@@ -47,59 +183,73 @@
</div> </div>
{% endif %} {% endif %}
{% if posts %} <div class="kanban-board">
<div class="card-bg rounded-xl border overflow-hidden"> <!-- Column: Vorschlag (draft) -->
<div class="p-4"> <div class="kanban-column column-draft">
<div class="grid gap-3"> <div class="kanban-header">
{% for post in posts %} <h3 class="text-yellow-400">
<a href="/posts/{{ post.id }}" class="post-card rounded-xl p-4 block group"> <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.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
<div class="flex items-center gap-4"> Vorschlag
<!-- Score Circle --> </h3>
{% if post.critic_feedback and post.critic_feedback | length > 0 %} <span class="kanban-count" id="count-draft">{{ posts | selectattr('status', 'equalto', 'draft') | list | length }}</span>
{% set score = post.critic_feedback[-1].overall_score %} </div>
<div class="score-ring flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}"> <div class="kanban-body" data-status="draft" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
{{ score }} {% for post in posts if post.status == 'draft' %}
</div> {{ render_post_card(post) }}
{% else %} {% else %}
<div class="score-ring flex-shrink-0 bg-brand-bg-dark border-2 border-brand-bg-light text-gray-500"> <div class="empty-column" id="empty-draft">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
</div> <p>Keine Vorschläge</p>
{% endif %} </div>
{% endfor %}
</div>
</div>
<!-- Content --> <!-- Column: Bearbeitet (approved) - waiting for customer approval -->
<div class="flex-1 min-w-0"> <div class="kanban-column column-approved">
<div class="flex items-start justify-between gap-3"> <div class="kanban-header">
<h4 class="font-medium text-white group-hover:text-brand-highlight transition-colors truncate"> <h3 class="text-blue-400">
{{ post.topic_title or 'Untitled' }} <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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</h4> Bearbeitet
<span class="flex-shrink-0 px-2 py-0.5 text-xs rounded font-medium {{ 'bg-green-600/20 text-green-400 border border-green-600/30' if post.status == 'approved' else 'bg-yellow-600/20 text-yellow-400 border border-yellow-600/30' }}"> </h3>
{{ post.status | capitalize }} <span class="kanban-count" id="count-approved">{{ posts | selectattr('status', 'equalto', 'approved') | list | length }}</span>
</span> </div>
</div> <div class="kanban-body" data-status="approved" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
<div class="flex items-center gap-4 mt-1.5 text-sm text-gray-500"> {% for post in posts if post.status == 'approved' %}
<span class="flex items-center gap-1.5"> {{ render_post_card(post) }}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg> {% else %}
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }} <div class="empty-column" id="empty-approved">
</span> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
<span class="flex items-center gap-1.5"> <p>Keine bearbeiteten Posts</p>
<svg class="w-3.5 h-3.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> </div>
{{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }} {% endfor %}
</span> </div>
</div> </div>
</div>
<!-- Arrow --> <!-- Column: Freigegeben (ready) - approved by customer, ready for calendar scheduling -->
<svg class="w-5 h-5 text-gray-600 group-hover:text-brand-highlight transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="kanban-column column-ready">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> <div class="kanban-header">
</svg> <h3 class="text-green-400">
</div> <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>
</a> Freigegeben
</h3>
<span class="kanban-count" id="count-ready">{{ posts | selectattr('status', 'equalto', 'ready') | list | length }}</span>
</div>
<div class="kanban-body" data-status="ready" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
{% for post in posts if post.status == 'ready' %}
{{ render_post_card(post) }}
{% else %}
<div class="empty-column" id="empty-ready">
<svg 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>
<p>Keine freigegebenen Posts</p>
</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div> </div>
{% else %}
<div class="card-bg rounded-xl border p-12 text-center"> {% if not posts %}
<div class="card-bg rounded-xl border p-12 text-center mt-6">
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6"> <div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg> <svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</div> </div>
@@ -111,4 +261,149 @@
</a> </a>
</div> </div>
{% endif %} {% endif %}
{% endblock %}
{% block scripts %}
<script>
let draggedElement = null;
let sourceStatus = null;
function handleDragStart(e) {
draggedElement = e.target;
sourceStatus = e.target.closest('.kanban-body').dataset.status;
e.target.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', e.target.dataset.postId);
}
function handleDragEnd(e) {
e.target.classList.remove('dragging');
document.querySelectorAll('.kanban-body').forEach(body => {
body.classList.remove('drag-over');
});
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const kanbanBody = e.target.closest('.kanban-body');
if (kanbanBody) {
kanbanBody.classList.add('drag-over');
}
}
function handleDragLeave(e) {
const kanbanBody = e.target.closest('.kanban-body');
if (kanbanBody && !kanbanBody.contains(e.relatedTarget)) {
kanbanBody.classList.remove('drag-over');
}
}
async function handleDrop(e) {
e.preventDefault();
const kanbanBody = e.target.closest('.kanban-body');
if (!kanbanBody || !draggedElement) return;
kanbanBody.classList.remove('drag-over');
const newStatus = kanbanBody.dataset.status;
const postId = draggedElement.dataset.postId;
// Don't do anything if dropped in same column
if (sourceStatus === newStatus) return;
// Remove empty placeholder if exists
const emptyPlaceholder = kanbanBody.querySelector('.empty-column');
if (emptyPlaceholder) {
emptyPlaceholder.remove();
}
// Move card to new column
kanbanBody.appendChild(draggedElement);
// Check if source column is now empty
const sourceBody = document.querySelector(`.kanban-body[data-status="${sourceStatus}"]`);
if (sourceBody && sourceBody.querySelectorAll('.post-card').length === 0) {
addEmptyPlaceholder(sourceBody, sourceStatus);
}
// Update counts
updateCounts();
// Update status in backend
try {
const formData = new FormData();
formData.append('status', newStatus);
const response = await fetch(`/api/posts/${postId}/status`, {
method: 'PATCH',
body: formData
});
if (!response.ok) {
throw new Error('Failed to update status');
}
} catch (error) {
console.error('Error updating status:', error);
showToast('Fehler beim Aktualisieren des Status', 'error');
// Revert the move
location.reload();
}
}
function addEmptyPlaceholder(container, status) {
const icons = {
'draft': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>',
'approved': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>',
'ready': '<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"/>'
};
const labels = {
'draft': 'Keine Vorschläge',
'approved': 'Keine bearbeiteten Posts',
'ready': 'Keine freigegebenen Posts'
};
const placeholder = document.createElement('div');
placeholder.className = 'empty-column';
placeholder.id = `empty-${status}`;
placeholder.innerHTML = `
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">${icons[status]}</svg>
<p>${labels[status]}</p>
`;
container.appendChild(placeholder);
}
function updateCounts() {
['draft', 'approved', 'ready'].forEach(status => {
const count = document.querySelectorAll(`.kanban-body[data-status="${status}"] .post-card`).length;
document.getElementById(`count-${status}`).textContent = count;
});
}
function getStatusLabel(status) {
const labels = {
'draft': 'Vorschlag',
'approved': 'Bearbeitet',
'ready': 'Freigegeben'
};
return labels[status] || status;
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg z-50 transition-all transform ${
type === 'success' ? 'bg-green-600 text-white' :
type === 'error' ? 'bg-red-600 text-white' :
'bg-brand-bg-light text-white'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('opacity-0');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
</script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Registrierung - LinkedIn Posts</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'brand': {
'bg': '#3d4848',
'bg-light': '#4a5858',
'bg-dark': '#2d3838',
'highlight': '#ffc700',
'highlight-dark': '#e6b300',
},
'linkedin': '#0A66C2'
}
}
}
}
</script>
<style>
body { background-color: #3d4848; }
.card-bg { background-color: #4a5858; border-color: #5a6868; }
.btn-primary { background-color: #ffc700; color: #2d3838; }
.btn-primary:hover { background-color: #e6b300; }
</style>
</head>
<body class="text-gray-100 min-h-screen flex items-center justify-center">
<div class="w-full max-w-2xl px-4">
<div class="card-bg rounded-xl border p-8">
<div class="text-center mb-8">
<img src="/static/logo.png" alt="Logo" class="h-16 w-auto mx-auto mb-4">
<h1 class="text-2xl font-bold text-white mb-2">Unternehmen registrieren</h1>
<p class="text-gray-400">Erstelle ein Konto für dein Unternehmen</p>
</div>
<!-- GHOSTWRITER FEATURE DISABLED -->
<!-- To re-enable: Uncomment ghostwriter option below and change grid to md:grid-cols-2 -->
<div class="mb-8">
<!-- Ghostwriter Option (DISABLED) -->
<!--
<a href="/register/ghostwriter" class="block p-6 rounded-xl border border-gray-600 hover:border-brand-highlight transition-colors group">
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-4 group-hover:bg-brand-highlight/30">
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">Ghostwriter</h3>
<p class="text-gray-400 text-sm">
Erstelle LinkedIn-Posts fur dich selbst oder fur einen einzelnen Kunden.
Ideal fur Freelancer und Content-Creator.
</p>
<div class="mt-4 text-brand-highlight text-sm font-medium group-hover:underline">
Als Ghostwriter starten &rarr;
</div>
</a>
-->
<!-- Company Option -->
<a href="/register/company" class="block p-6 rounded-xl border border-brand-highlight bg-brand-highlight/5 transition-colors group max-w-lg mx-auto">
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-4 group-hover:bg-brand-highlight/30">
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">Unternehmen</h3>
<p class="text-gray-400 text-sm">
Verwalte mehrere Mitarbeiter-Konten mit einer einheitlichen Unternehmensstrategie.
Ideal fur Teams und Agenturen.
</p>
<div class="mt-4 text-brand-highlight text-sm font-medium group-hover:underline">
Jetzt registrieren &rarr;
</div>
</a>
</div>
<div class="text-center pt-6 border-t border-gray-600">
<p class="text-gray-400 text-sm">
Du hast bereits ein Konto?
<a href="/login" class="text-brand-highlight hover:underline">Anmelden</a>
</p>
</div>
</div>
<div class="mt-6 text-center">
<a href="/admin/login" class="text-gray-500 hover:text-gray-300 text-sm">
Admin-Login
</a>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unternehmens Registrierung - LinkedIn Posts</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'brand': {
'bg': '#3d4848',
'bg-light': '#4a5858',
'bg-dark': '#2d3838',
'highlight': '#ffc700',
'highlight-dark': '#e6b300',
},
'linkedin': '#0A66C2'
}
}
}
}
</script>
<style>
body { background-color: #3d4848; }
.btn-linkedin { background-color: #0A66C2; }
.btn-linkedin:hover { background-color: #004182; }
.card-bg { background-color: #4a5858; border-color: #5a6868; }
.input-bg { background-color: #3d4848; border-color: #5a6868; }
.input-bg:focus { border-color: #ffc700; outline: none; }
.btn-primary { background-color: #ffc700; color: #2d3838; }
.btn-primary:hover { background-color: #e6b300; }
</style>
</head>
<body class="text-gray-100 min-h-screen flex items-center justify-center">
<div class="w-full max-w-md px-4">
<div class="card-bg rounded-xl border p-8">
<div class="text-center mb-8">
<img src="/static/logo.png" alt="Logo" class="h-16 w-auto mx-auto mb-4">
<h1 class="text-2xl font-bold text-white mb-2">Unternehmens-Konto</h1>
<p class="text-gray-400">Registriere dein Unternehmen</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 %}
<!-- LinkedIn OAuth -->
<div class="mb-6">
<a href="/auth/linkedin?account_type=company" class="w-full btn-linkedin text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-3">
<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 registrieren (empfohlen)
</a>
</div>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-brand-bg-light text-gray-400">oder mit E-Mail</span>
</div>
</div>
<!-- Email/Password Form -->
<form method="POST" action="/register/company" class="space-y-4">
<div>
<label for="license_key" class="block text-sm font-medium text-gray-300 mb-1">
Lizenzschlüssel *
<span class="text-xs text-gray-500 font-normal ml-2">
(Format: XXXX-XXXX-XXXX-XXXX)
</span>
</label>
<input type="text"
id="license_key"
name="license_key"
placeholder="ABCD-EFGH-IJKL-MNOP"
pattern="[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}"
required
class="w-full input-bg border rounded-lg px-4 py-2 text-white placeholder-gray-500"
maxlength="19"
style="text-transform: uppercase;">
<p class="text-xs text-gray-500 mt-1">
Bitte gib deinen Lizenzschlüssel ein, um dich zu registrieren.
</p>
</div>
<div>
<label for="company_name" class="block text-sm font-medium text-gray-300 mb-1">Unternehmensname</label>
<input type="text" id="company_name" name="company_name" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Dein Unternehmen GmbH">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-300 mb-1">E-Mail</label>
<input type="email" id="email" name="email" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="admin@unternehmen.de">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300 mb-1">Passwort</label>
<input type="password" id="password" name="password" required minlength="8"
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Mindestens 8 Zeichen">
<p class="text-xs text-gray-500 mt-1">Mind. 8 Zeichen, 1 Großbuchstabe, 1 Zahl</p>
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-gray-300 mb-1">Passwort bestätigen</label>
<input type="password" id="password_confirm" name="password_confirm" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Passwort wiederholen">
</div>
<button type="submit" class="w-full btn-primary font-medium py-3 px-4 rounded-lg transition-colors">
Unternehmen registrieren
</button>
</form>
<div class="text-center pt-6 mt-6 border-t border-gray-600">
<p class="text-gray-400 text-sm">
Du hast bereits ein Konto?
<a href="/login" class="text-brand-highlight hover:underline">Anmelden</a>
</p>
<p class="text-gray-500 text-sm mt-2">
<a href="/register" class="hover:text-gray-300">&larr; Kontotyp ändern</a>
</p>
</div>
</div>
</div>
<script>
// License key uppercase formatting
const licenseKeyInput = document.getElementById('license_key');
licenseKeyInput.addEventListener('input', (e) => {
e.target.value = e.target.value.toUpperCase();
});
// Password confirmation validation
const form = document.querySelector('form');
const password = document.getElementById('password');
const passwordConfirm = document.getElementById('password_confirm');
form.addEventListener('submit', function(e) {
if (password.value !== passwordConfirm.value) {
e.preventDefault();
alert('Passwörter stimmen nicht überein');
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ghostwriter Registrierung - LinkedIn Posts</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'brand': {
'bg': '#3d4848',
'bg-light': '#4a5858',
'bg-dark': '#2d3838',
'highlight': '#ffc700',
'highlight-dark': '#e6b300',
},
'linkedin': '#0A66C2'
}
}
}
}
</script>
<style>
body { background-color: #3d4848; }
.btn-linkedin { background-color: #0A66C2; }
.btn-linkedin:hover { background-color: #004182; }
.card-bg { background-color: #4a5858; border-color: #5a6868; }
.input-bg { background-color: #3d4848; border-color: #5a6868; }
.input-bg:focus { border-color: #ffc700; outline: none; }
.btn-primary { background-color: #ffc700; color: #2d3838; }
.btn-primary:hover { background-color: #e6b300; }
</style>
</head>
<body class="text-gray-100 min-h-screen flex items-center justify-center">
<div class="w-full max-w-md px-4">
<div class="card-bg rounded-xl border p-8">
<div class="text-center mb-8">
<img src="/static/logo.png" alt="Logo" class="h-16 w-auto mx-auto mb-4">
<h1 class="text-2xl font-bold text-white mb-2">Ghostwriter Konto</h1>
<p class="text-gray-400">Erstelle dein persönliches Konto</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 %}
<!-- LinkedIn OAuth -->
<div class="mb-6">
<a href="/auth/linkedin?account_type=ghostwriter" class="w-full btn-linkedin text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-3">
<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 registrieren (empfohlen)
</a>
</div>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-brand-bg-light text-gray-400">oder mit E-Mail</span>
</div>
</div>
<!-- Email/Password Form -->
<form method="POST" action="/register/ghostwriter" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-300 mb-1">E-Mail</label>
<input type="email" id="email" name="email" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="deine@email.de">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300 mb-1">Passwort</label>
<input type="password" id="password" name="password" required minlength="8"
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Mindestens 8 Zeichen">
<p class="text-xs text-gray-500 mt-1">Mind. 8 Zeichen, 1 Großbuchstabe, 1 Zahl</p>
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-gray-300 mb-1">Passwort bestätigen</label>
<input type="password" id="password_confirm" name="password_confirm" required
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
placeholder="Passwort wiederholen">
</div>
<button type="submit" class="w-full btn-primary font-medium py-3 px-4 rounded-lg transition-colors">
Konto erstellen
</button>
</form>
<div class="text-center pt-6 mt-6 border-t border-gray-600">
<p class="text-gray-400 text-sm">
Du hast bereits ein Konto?
<a href="/login" class="text-brand-highlight hover:underline">Anmelden</a>
</p>
<p class="text-gray-500 text-sm mt-2">
<a href="/register" class="hover:text-gray-300">&larr; Kontotyp ändern</a>
</p>
</div>
</div>
</div>
<script>
// Password confirmation validation
const form = document.querySelector('form');
const password = document.getElementById('password');
const passwordConfirm = document.getElementById('password_confirm');
form.addEventListener('submit', function(e) {
if (password.value !== passwordConfirm.value) {
e.preventDefault();
alert('Passwörter stimmen nicht überein');
}
});
</script>
</body>
</html>

View File

@@ -7,6 +7,21 @@
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p> <p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p>
</div> </div>
<!-- Limit Warning -->
{% if limit_reached %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl mb-8">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 flex-shrink-0" 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>
<strong class="font-semibold">Limit erreicht</strong>
<p class="text-sm mt-1">{{ limit_message }}</p>
</div>
</div>
</div>
{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left: Form --> <!-- Left: Form -->
<div> <div>
@@ -34,9 +49,10 @@
</div> </div>
</div> </div>
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2"> <button type="submit" id="submitBtn" {% if limit_reached %}disabled{% endif %}
class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
Research starten {% if limit_reached %}Limit erreicht{% else %}Research starten{% endif %}
</button> </button>
</form> </form>
</div> </div>

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 %}

View File

@@ -21,14 +21,20 @@
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-full flex items-center justify-center overflow-hidden {{ 'bg-brand-highlight' if not profile_picture else '' }}"> <div class="w-12 h-12 rounded-full flex items-center justify-center overflow-hidden {{ 'bg-brand-highlight' if not profile_picture else '' }}">
{% if profile_picture %} {% if profile_picture %}
<img src="{{ profile_picture }}" alt="{{ customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer"> <img src="{{ profile_picture }}" alt="{{ profile.display_name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
{% else %} {% else %}
<span class="text-brand-bg-dark font-bold text-lg">{{ customer.name[0] | upper }}</span> <span class="text-brand-bg-dark font-bold text-lg">{{ (profile.display_name or 'U')[0] | upper }}</span>
{% endif %} {% endif %}
</div> </div>
<div> <div>
<h3 class="font-semibold text-white text-lg">{{ customer.name }}</h3> <h3 class="font-semibold text-white text-lg">{{ profile.display_name or session.linkedin_name or 'Unbekannt' }}</h3>
<p class="text-sm text-gray-400">{{ customer.company_name or 'Kein Unternehmen' }}</p> <p class="text-sm text-gray-400">
{% if session.account_type == 'ghostwriter' %}
Ghostwriter
{% else %}
{{ session.company_name or 'Kein Unternehmen' }}
{% endif %}
</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">

View File

@@ -1,123 +1,35 @@
"""User authentication with Supabase LinkedIn OAuth.""" """User authentication with Supabase Auth.
import re
Uses Supabase's built-in authentication for:
- Email/password signup and login
- LinkedIn OAuth
- Session management via JWT tokens
"""
import secrets import secrets
from typing import Optional from typing import Optional
from uuid import UUID from uuid import UUID
from fastapi import Request, Response from fastapi import Request, Response
from loguru import logger from loguru import logger
from supabase import create_client
from src.config import settings from src.config import settings
from src.database import db from src.database import db
# Session management # Session management - using Supabase JWT tokens stored in cookies
USER_SESSION_COOKIE = "linkedin_user_session" USER_SESSION_COOKIE = "sb_session" # Supabase session cookie
REFRESH_TOKEN_COOKIE = "sb_refresh_token"
SESSION_SECRET = settings.session_secret or secrets.token_hex(32) SESSION_SECRET = settings.session_secret or secrets.token_hex(32)
# Supabase client for auth operations
_supabase_client = None
def normalize_linkedin_url(url: str) -> str: def get_supabase():
"""Normalize LinkedIn URL for comparison. """Get or create Supabase client."""
global _supabase_client
Extracts the username/vanityName from various LinkedIn URL formats. if _supabase_client is None:
""" _supabase_client = create_client(settings.supabase_url, settings.supabase_key)
if not url: return _supabase_client
return ""
# Match linkedin.com/in/username with optional trailing slash or query params
match = re.search(r'linkedin\.com/in/([^/?]+)', url.lower())
if match:
return match.group(1).rstrip('/')
return url.lower().strip()
async def get_customer_by_vanity_name(vanity_name: str) -> Optional[dict]:
"""Find customer by LinkedIn vanityName.
Constructs the LinkedIn URL from vanityName and matches against
Customer.linkedin_url (normalized).
"""
if not vanity_name:
return None
normalized_vanity = normalize_linkedin_url(f"https://www.linkedin.com/in/{vanity_name}/")
# Get all customers and match
customers = await db.list_customers()
for customer in customers:
customer_vanity = normalize_linkedin_url(customer.linkedin_url)
if customer_vanity == normalized_vanity:
return {
"id": str(customer.id),
"name": customer.name,
"linkedin_url": customer.linkedin_url,
"company_name": customer.company_name,
"email": customer.email
}
return None
async def get_customer_by_email(email: str) -> Optional[dict]:
"""Find customer by email address.
Fallback matching when LinkedIn vanityName is not available.
"""
if not email:
return None
email_lower = email.lower().strip()
# Get all customers and match by email
customers = await db.list_customers()
for customer in customers:
if customer.email and customer.email.lower().strip() == email_lower:
return {
"id": str(customer.id),
"name": customer.name,
"linkedin_url": customer.linkedin_url,
"company_name": customer.company_name,
"email": customer.email
}
return None
async def get_customer_by_name(name: str) -> Optional[dict]:
"""Find customer by name.
Fallback matching when email is not available.
Tries exact match first, then case-insensitive.
"""
if not name:
return None
name_lower = name.lower().strip()
# Get all customers and match by name
customers = await db.list_customers()
# First try exact match
for customer in customers:
if customer.name == name:
return {
"id": str(customer.id),
"name": customer.name,
"linkedin_url": customer.linkedin_url,
"company_name": customer.company_name,
"email": customer.email
}
# Then try case-insensitive
for customer in customers:
if customer.name.lower().strip() == name_lower:
return {
"id": str(customer.id),
"name": customer.name,
"linkedin_url": customer.linkedin_url,
"company_name": customer.company_name,
"email": customer.email
}
return None
class UserSession: class UserSession:
@@ -125,19 +37,47 @@ class UserSession:
def __init__( def __init__(
self, self,
customer_id: str, user_id: Optional[str] = None,
customer_name: str, linkedin_vanity_name: str = "",
linkedin_vanity_name: str,
linkedin_name: Optional[str] = None, linkedin_name: Optional[str] = None,
linkedin_picture: Optional[str] = None, linkedin_picture: Optional[str] = None,
email: Optional[str] = None email: Optional[str] = None,
account_type: str = "ghostwriter",
company_id: Optional[str] = None,
onboarding_status: str = "completed",
company_name: Optional[str] = None,
display_name: Optional[str] = None
): ):
self.customer_id = customer_id self.user_id = user_id
self.customer_name = customer_name
self.linkedin_vanity_name = linkedin_vanity_name self.linkedin_vanity_name = linkedin_vanity_name
self.linkedin_name = linkedin_name self.linkedin_name = linkedin_name
self.linkedin_picture = linkedin_picture self.linkedin_picture = linkedin_picture
self.email = email self.email = email
self.account_type = account_type
self.company_id = company_id
self.onboarding_status = onboarding_status
self.company_name = company_name
self.display_name = display_name or linkedin_name
@property
def is_onboarding_complete(self) -> bool:
"""Check if user has completed onboarding."""
return self.onboarding_status == "completed"
@property
def is_company_owner(self) -> bool:
"""Check if user is a company owner."""
return self.account_type == "company"
@property
def is_employee(self) -> bool:
"""Check if user is an employee."""
return self.account_type == "employee"
@property
def is_ghostwriter(self) -> bool:
"""Check if user is a ghostwriter."""
return self.account_type == "ghostwriter"
def to_cookie_value(self) -> str: def to_cookie_value(self) -> str:
"""Serialize session to cookie value.""" """Serialize session to cookie value."""
@@ -145,12 +85,16 @@ class UserSession:
import hashlib import hashlib
data = { data = {
"customer_id": self.customer_id, "user_id": self.user_id,
"customer_name": self.customer_name,
"linkedin_vanity_name": self.linkedin_vanity_name, "linkedin_vanity_name": self.linkedin_vanity_name,
"linkedin_name": self.linkedin_name, "linkedin_name": self.linkedin_name,
"linkedin_picture": self.linkedin_picture, "linkedin_picture": self.linkedin_picture,
"email": self.email "email": self.email,
"account_type": self.account_type,
"company_id": self.company_id,
"onboarding_status": self.onboarding_status,
"company_name": self.company_name,
"display_name": self.display_name
} }
# Create signed cookie value # Create signed cookie value
@@ -184,12 +128,16 @@ class UserSession:
data = json.loads(json_data) data = json.loads(json_data)
return cls( return cls(
customer_id=data["customer_id"], user_id=data.get("user_id"),
customer_name=data["customer_name"], linkedin_vanity_name=data.get("linkedin_vanity_name", ""),
linkedin_vanity_name=data["linkedin_vanity_name"],
linkedin_name=data.get("linkedin_name"), linkedin_name=data.get("linkedin_name"),
linkedin_picture=data.get("linkedin_picture"), linkedin_picture=data.get("linkedin_picture"),
email=data.get("email") email=data.get("email"),
account_type=data.get("account_type", "ghostwriter"),
company_id=data.get("company_id"),
onboarding_status=data.get("onboarding_status", "completed"),
company_name=data.get("company_name"),
display_name=data.get("display_name")
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to parse session cookie: {e}") logger.error(f"Failed to parse session cookie: {e}")
@@ -198,146 +146,354 @@ class UserSession:
def get_user_session(request: Request) -> Optional[UserSession]: def get_user_session(request: Request) -> Optional[UserSession]:
"""Get user session from request cookies.""" """Get user session from request cookies."""
cookie = request.cookies.get(USER_SESSION_COOKIE) # Try legacy cookie first (contains full session data including profile info)
if not cookie: legacy_cookie = request.cookies.get("linkedin_user_session")
return None if legacy_cookie:
return UserSession.from_cookie_value(cookie) session = UserSession.from_cookie_value(legacy_cookie)
if session:
return session
# Fallback to Supabase session validation
access_token = request.cookies.get(USER_SESSION_COOKIE)
if access_token:
try:
supabase = get_supabase()
user_response = supabase.auth.get_user(access_token)
if user_response and user_response.user:
return _create_session_from_supabase_user(user_response.user)
except Exception as e:
logger.debug(f"Could not validate Supabase session: {e}")
return None
def set_user_session(response: Response, session: UserSession) -> None: async def get_user_session_async(request: Request) -> Optional[UserSession]:
"""Set user session cookie.""" """Async version of get_user_session with profile lookup."""
session = get_user_session(request)
if session and session.user_id:
# Fetch additional profile data if needed
try:
user = await db.get_user(UUID(session.user_id))
if user:
session.onboarding_status = user.onboarding_status.value if hasattr(user.onboarding_status, 'value') else user.onboarding_status
session.account_type = user.account_type.value if hasattr(user.account_type, 'value') else user.account_type
session.company_id = str(user.company_id) if user.company_id else None
except Exception as e:
logger.warning(f"Could not fetch profile data: {e}")
return session
def _create_session_from_supabase_user(user) -> UserSession:
"""Create UserSession from Supabase user object."""
user_metadata = user.user_metadata or {}
return UserSession(
user_id=str(user.id),
linkedin_vanity_name=user_metadata.get("vanityName", ""),
linkedin_name=user_metadata.get("name"),
linkedin_picture=user_metadata.get("picture"),
email=user.email,
account_type=user_metadata.get("account_type", "ghostwriter"),
company_id=None, # Will be fetched from profile
onboarding_status="pending", # Will be fetched from profile
company_name=None,
display_name=user_metadata.get("display_name") or user_metadata.get("name")
)
def set_user_session(response: Response, session: UserSession, access_token: str = None, refresh_token: str = None) -> None:
"""Set user session cookies."""
if access_token:
response.set_cookie(
key=USER_SESSION_COOKIE,
value=access_token,
httponly=True,
max_age=60 * 60,
samesite="lax",
secure=True
)
if refresh_token:
response.set_cookie(
key=REFRESH_TOKEN_COOKIE,
value=refresh_token,
httponly=True,
max_age=60 * 60 * 24 * 7,
samesite="lax",
secure=True
)
# Also set legacy cookie for backwards compatibility
response.set_cookie( response.set_cookie(
key=USER_SESSION_COOKIE, key="linkedin_user_session",
value=session.to_cookie_value(), value=session.to_cookie_value(),
httponly=True, httponly=True,
max_age=60 * 60 * 24 * 7, # 7 days max_age=60 * 60 * 24 * 7,
samesite="lax" samesite="lax"
) )
def clear_user_session(response: Response) -> None: def clear_user_session(response: Response) -> None:
"""Clear user session cookie.""" """Clear all session cookies."""
response.delete_cookie(USER_SESSION_COOKIE) response.delete_cookie(USER_SESSION_COOKIE)
response.delete_cookie(REFRESH_TOKEN_COOKIE)
response.delete_cookie("linkedin_user_session")
async def handle_oauth_callback( async def handle_oauth_callback(
access_token: str, access_token: str,
refresh_token: Optional[str] = None refresh_token: Optional[str] = None,
) -> Optional[UserSession]: allow_registration: bool = True,
"""Handle OAuth callback from Supabase. account_type: str = "ghostwriter"
) -> tuple[Optional[UserSession], Optional[str], Optional[str]]:
"""Handle OAuth callback from Supabase Auth.
1. Get user info from Supabase using access token Supabase Auth handles the user creation in auth.users automatically.
2. Extract LinkedIn vanityName from user metadata Our trigger creates the profile in the profiles table.
3. Match with Customer record
4. Create session if match found
Returns UserSession if authorized, None if not. Returns:
Tuple of (UserSession, access_token, refresh_token) if authorized,
(None, None, None) if not.
""" """
from supabase import create_client from src.database.models import Profile, AccountType, OnboardingStatus
try: try:
# Create a new client with the user's access token supabase = get_supabase()
supabase = create_client(settings.supabase_url, settings.supabase_key)
# Get user info using the access token # Get user info using the access token
user_response = supabase.auth.get_user(access_token) user_response = supabase.auth.get_user(access_token)
if not user_response or not user_response.user: if not user_response or not user_response.user:
logger.error("Failed to get user from Supabase") logger.error("Failed to get user from Supabase")
return None return None, None, None
user = user_response.user user = user_response.user
user_metadata = user.user_metadata or {} user_metadata = user.user_metadata or {}
# Debug: Log full response
import json import json
logger.info(f"=== FULL OAUTH RESPONSE ===") logger.info(f"=== OAUTH CALLBACK ===")
logger.info(f"user.id: {user.id}") logger.info(f"user.id: {user.id}")
logger.info(f"user.email: {user.email}") logger.info(f"user.email: {user.email}")
logger.info(f"user.phone: {user.phone}")
logger.info(f"user.app_metadata: {json.dumps(user.app_metadata, indent=2)}")
logger.info(f"user.user_metadata: {json.dumps(user.user_metadata, indent=2)}") logger.info(f"user.user_metadata: {json.dumps(user.user_metadata, indent=2)}")
logger.info(f"--- Einzelne Felder ---")
logger.info(f"given_name: {user_metadata.get('given_name')}")
logger.info(f"family_name: {user_metadata.get('family_name')}")
logger.info(f"name: {user_metadata.get('name')}")
logger.info(f"email (metadata): {user_metadata.get('email')}")
logger.info(f"picture: {user_metadata.get('picture')}")
logger.info(f"sub: {user_metadata.get('sub')}")
logger.info(f"provider_id: {user_metadata.get('provider_id')}")
logger.info(f"=== END OAUTH RESPONSE ===")
# LinkedIn OIDC provides these fields vanity_name = user_metadata.get("vanityName")
vanity_name = user_metadata.get("vanityName") # LinkedIn username (often not provided)
name = user_metadata.get("name") name = user_metadata.get("name")
picture = user_metadata.get("picture") picture = user_metadata.get("picture")
email = user.email email = user.email
logger.info(f"OAuth callback for user: {name} (vanityName={vanity_name}, email={email})") logger.info(f"OAuth callback for user: {name} (vanityName={vanity_name}, email={email})")
# Try to match with customer # Check if profile exists (should be created by trigger)
customer = None profile = await db.get_profile(UUID(str(user.id)))
# First try vanityName if available if not profile:
if vanity_name: logger.info(f"Profile not found for user {user.id}, creating...")
customer = await get_customer_by_vanity_name(vanity_name) profile = Profile(
if customer: account_type=AccountType(account_type),
logger.info(f"Matched by vanityName: {vanity_name}") onboarding_status=OnboardingStatus.PENDING,
display_name=name
)
profile = await db.create_profile(UUID(str(user.id)), profile)
# Fallback to email matching # Get company name if applicable
if not customer and email: company_name = None
customer = await get_customer_by_email(email) if profile.company_id:
if customer: company = await db.get_company(profile.company_id)
logger.info(f"Matched by email: {email}") if company:
company_name = company.name
# Fallback to name matching session = UserSession(
if not customer and name: user_id=str(user.id),
customer = await get_customer_by_name(name) linkedin_vanity_name=vanity_name or "",
if customer:
logger.info(f"Matched by name: {name}")
if not customer:
# Debug: List all customers to help diagnose
all_customers = await db.list_customers()
logger.warning(f"No customer found for LinkedIn user: {name} (email={email}, vanityName={vanity_name})")
logger.warning(f"Available customers:")
for c in all_customers:
logger.warning(f" - {c.name}: email={c.email}, linkedin={c.linkedin_url}")
return None
logger.info(f"User {name} matched with customer {customer['name']}")
# Use vanityName from OAuth or extract from customer's linkedin_url
effective_vanity_name = vanity_name
if not effective_vanity_name and customer.get("linkedin_url"):
effective_vanity_name = normalize_linkedin_url(customer["linkedin_url"])
return UserSession(
customer_id=customer["id"],
customer_name=customer["name"],
linkedin_vanity_name=effective_vanity_name or "",
linkedin_name=name, linkedin_name=name,
linkedin_picture=picture, linkedin_picture=picture,
email=email email=email,
account_type=profile.account_type.value if hasattr(profile.account_type, 'value') else profile.account_type,
company_id=str(profile.company_id) if profile.company_id else None,
onboarding_status=profile.onboarding_status.value if hasattr(profile.onboarding_status, 'value') else profile.onboarding_status,
company_name=company_name,
display_name=profile.display_name or name
) )
return session, access_token, refresh_token
except Exception as e: except Exception as e:
logger.exception(f"OAuth callback error: {e}") logger.exception(f"OAuth callback error: {e}")
return None return None, None, None
async def handle_email_password_login(email: str, password: str) -> tuple[Optional[UserSession], Optional[str], Optional[str]]:
"""Handle email/password login via Supabase Auth."""
try:
supabase = get_supabase()
auth_response = supabase.auth.sign_in_with_password({
"email": email.lower(),
"password": password
})
if not auth_response or not auth_response.user:
logger.warning(f"Failed login attempt for: {email}")
return None, None, None
user = auth_response.user
session = auth_response.session
logger.info(f"Successful email/password login for: {email}")
# Get profile data
profile = await db.get_profile(UUID(str(user.id)))
if not profile:
logger.error(f"No profile found for user {user.id}")
return None, None, None
# Get company name if applicable
company_name = None
if profile.company_id:
company = await db.get_company(profile.company_id)
if company:
company_name = company.name
user_session = UserSession(
user_id=str(user.id),
linkedin_vanity_name="",
linkedin_name=profile.display_name,
linkedin_picture=None,
email=user.email,
account_type=profile.account_type.value if hasattr(profile.account_type, 'value') else profile.account_type,
company_id=str(profile.company_id) if profile.company_id else None,
onboarding_status=profile.onboarding_status.value if hasattr(profile.onboarding_status, 'value') else profile.onboarding_status,
company_name=company_name,
display_name=profile.display_name
)
return user_session, session.access_token, session.refresh_token
except Exception as e:
logger.warning(f"Email/password login error: {e}")
return None, None, None
async def create_email_password_user(
email: str,
password: str,
account_type: str = "ghostwriter",
company_id: Optional[str] = None,
display_name: Optional[str] = None
) -> tuple[Optional[UserSession], Optional[str], Optional[str], Optional[str]]:
"""Create a new user with email/password authentication via Supabase Auth."""
from src.database.models import Profile, AccountType, OnboardingStatus
from src.web.user.password_auth import validate_password_strength
try:
# Validate password
is_valid, error_msg = validate_password_strength(password)
if not is_valid:
logger.warning(f"Weak password for registration: {error_msg}")
return None, None, None, error_msg
supabase = get_supabase()
# Sign up via Supabase Auth
auth_response = supabase.auth.sign_up({
"email": email.lower(),
"password": password,
"options": {
"data": {
"account_type": account_type,
"display_name": display_name
}
}
})
if not auth_response or not auth_response.user:
logger.warning(f"Failed to create user: {email}")
return None, None, None, "Registrierung fehlgeschlagen"
user = auth_response.user
session = auth_response.session
logger.info(f"Created new user via Supabase Auth: {user.id}")
# Wait a moment for the trigger to create the profile
import asyncio
await asyncio.sleep(0.5)
# Update profile with additional data
profile = await db.get_profile(UUID(str(user.id)))
if not profile:
logger.warning(f"Profile not created by trigger, creating manually")
acc_type = AccountType(account_type)
profile = Profile(
account_type=acc_type,
display_name=display_name,
onboarding_status=OnboardingStatus.PENDING
)
if company_id:
profile.company_id = UUID(company_id)
profile = await db.create_profile(UUID(str(user.id)), profile)
elif company_id or display_name:
updates = {}
if company_id:
updates["company_id"] = company_id
if display_name:
updates["display_name"] = display_name
if updates:
profile = await db.update_profile(UUID(str(user.id)), updates)
# Get company name if applicable
company_name = None
if profile.company_id:
company = await db.get_company(profile.company_id)
if company:
company_name = company.name
user_session = UserSession(
user_id=str(user.id),
linkedin_vanity_name="",
linkedin_name=display_name,
linkedin_picture=None,
email=user.email,
account_type=account_type,
company_id=company_id,
onboarding_status="pending",
company_name=company_name,
display_name=display_name
)
access_token = session.access_token if session else None
refresh_token = session.refresh_token if session else None
return user_session, access_token, refresh_token, None
except Exception as e:
error_str = str(e)
logger.exception(f"Error creating email/password user: {e}")
if "already registered" in error_str.lower() or "already exists" in error_str.lower():
return None, None, None, "Diese E-Mail-Adresse ist bereits registriert"
return None, None, None, "Registrierung fehlgeschlagen"
async def sign_out(access_token: Optional[str] = None) -> bool:
"""Sign out user from Supabase Auth."""
try:
if access_token:
supabase = get_supabase()
supabase.auth.sign_out()
return True
except Exception as e:
logger.warning(f"Error signing out: {e}")
return False
def get_supabase_login_url(redirect_to: str) -> str: def get_supabase_login_url(redirect_to: str) -> str:
"""Generate Supabase OAuth login URL for LinkedIn. """Generate Supabase OAuth login URL for LinkedIn."""
Args:
redirect_to: The URL to redirect to after OAuth (the callback endpoint)
Returns:
The Supabase OAuth URL to redirect the user to
"""
from urllib.parse import urlencode from urllib.parse import urlencode
# Supabase OAuth endpoint
base_url = f"{settings.supabase_url}/auth/v1/authorize" base_url = f"{settings.supabase_url}/auth/v1/authorize"
params = { params = {
@@ -346,3 +502,18 @@ def get_supabase_login_url(redirect_to: str) -> str:
} }
return f"{base_url}?{urlencode(params)}" return f"{base_url}?{urlencode(params)}"
async def refresh_session(refresh_token: str) -> tuple[Optional[str], Optional[str]]:
"""Refresh the user's session using a refresh token."""
try:
supabase = get_supabase()
response = supabase.auth.refresh_session(refresh_token)
if response and response.session:
return response.session.access_token, response.session.refresh_token
return None, None
except Exception as e:
logger.warning(f"Error refreshing session: {e}")
return None, None

View File

@@ -0,0 +1,141 @@
"""Password authentication utilities."""
import secrets
import hashlib
import hmac
from datetime import datetime, timedelta
from typing import Optional, Tuple
import bcrypt
from loguru import logger
def hash_password(password: str) -> str:
"""Hash a password using bcrypt.
Args:
password: Plain text password
Returns:
Hashed password string
"""
# bcrypt automatically handles salting
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
return hashed.decode('utf-8')
def verify_password(password: str, password_hash: str) -> bool:
"""Verify a password against its hash.
Args:
password: Plain text password to verify
password_hash: Stored hash to compare against
Returns:
True if password matches, False otherwise
"""
try:
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
except Exception as e:
logger.error(f"Password verification error: {e}")
return False
def generate_verification_token() -> str:
"""Generate a secure email verification token.
Returns:
URL-safe token string (64 characters)
"""
return secrets.token_urlsafe(48)
def generate_invitation_token() -> str:
"""Generate a secure invitation token.
Returns:
URL-safe token string (32 characters)
"""
return secrets.token_urlsafe(24)
def generate_password_reset_token() -> str:
"""Generate a secure password reset token.
Returns:
URL-safe token string (64 characters)
"""
return secrets.token_urlsafe(48)
def get_verification_expiry(hours: int = 24) -> datetime:
"""Get expiry datetime for email verification token.
Args:
hours: Number of hours until expiry (default 24)
Returns:
Datetime when token expires (timezone-aware UTC)
"""
from datetime import timezone
return datetime.now(timezone.utc) + timedelta(hours=hours)
def get_invitation_expiry(days: int = 7) -> datetime:
"""Get expiry datetime for invitation token.
Args:
days: Number of days until expiry (default 7)
Returns:
Datetime when token expires (timezone-aware UTC)
"""
from datetime import timezone
return datetime.now(timezone.utc) + timedelta(days=days)
def is_token_expired(expires_at: datetime) -> bool:
"""Check if a token has expired.
Args:
expires_at: Expiry datetime of the token
Returns:
True if expired, False otherwise
"""
from datetime import timezone
now = datetime.now(timezone.utc)
# Handle both timezone-aware and naive datetimes
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
return now > expires_at
def validate_password_strength(password: str) -> Tuple[bool, Optional[str]]:
"""Validate password meets minimum requirements.
Requirements:
- Minimum 8 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
Args:
password: Password to validate
Returns:
Tuple of (is_valid, error_message)
"""
if len(password) < 8:
return False, "Passwort muss mindestens 8 Zeichen lang sein"
if not any(c.isupper() for c in password):
return False, "Passwort muss mindestens einen Großbuchstaben enthalten"
if not any(c.islower() for c in password):
return False, "Passwort muss mindestens einen Kleinbuchstaben enthalten"
if not any(c.isdigit() for c in password):
return False, "Passwort muss mindestens eine Zahl enthalten"
return True, None

File diff suppressed because it is too large Load Diff