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

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

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

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

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

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

View File

@@ -1,7 +1,6 @@
"""Database module."""
from src.database.client import DatabaseClient, db
from src.database.models import (
Customer,
LinkedInProfile,
LinkedInPost,
Topic,
@@ -9,12 +8,24 @@ from src.database.models import (
ResearchResult,
GeneratedPost,
PostType,
User,
Profile,
Company,
Invitation,
ExamplePost,
ReferenceProfile,
AccountType,
OnboardingStatus,
AuthMethod,
InvitationStatus,
ApiUsageLog,
LicenseKey,
CompanyDailyQuota,
)
__all__ = [
"DatabaseClient",
"db",
"Customer",
"LinkedInProfile",
"LinkedInPost",
"Topic",
@@ -22,4 +33,17 @@ __all__ = [
"ResearchResult",
"GeneratedPost",
"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."""
from datetime import datetime
from datetime import datetime, date
from enum import Enum
from typing import Optional, Dict, Any, List
from uuid import UUID
from pydantic import BaseModel, Field, ConfigDict
@@ -10,22 +11,210 @@ class DBModel(BaseModel):
model_config = ConfigDict(extra='ignore')
class Customer(DBModel):
"""Customer/Client model."""
# ==================== ENUMS ====================
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
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
name: str
email: Optional[str] = None
company_name: Optional[str] = None
linkedin_url: str
# Account Type
account_type: AccountType = AccountType.GHOSTWRITER
# 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)
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):
"""Post type model for categorizing different types of posts."""
id: Optional[UUID] = None
customer_id: UUID
user_id: UUID
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
name: str
@@ -42,7 +231,7 @@ class PostType(DBModel):
class LinkedInProfile(DBModel):
"""LinkedIn profile model."""
id: Optional[UUID] = None
customer_id: UUID
user_id: UUID
scraped_at: Optional[datetime] = None
profile_data: Dict[str, Any]
name: Optional[str] = None
@@ -55,7 +244,7 @@ class LinkedInProfile(DBModel):
class LinkedInPost(DBModel):
"""LinkedIn post model."""
id: Optional[UUID] = None
customer_id: UUID
user_id: UUID
scraped_at: Optional[datetime] = None
post_url: Optional[str] = None
post_text: str
@@ -73,7 +262,7 @@ class LinkedInPost(DBModel):
class Topic(DBModel):
"""Topic model."""
id: Optional[UUID] = None
customer_id: UUID
user_id: UUID
created_at: Optional[datetime] = None
title: str
description: Optional[str] = None
@@ -82,13 +271,13 @@ class Topic(DBModel):
extraction_confidence: Optional[float] = None
is_used: bool = False
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):
"""Profile analysis model."""
id: Optional[UUID] = None
customer_id: UUID
user_id: UUID
created_at: Optional[datetime] = None
writing_style: Dict[str, Any]
tone_analysis: Dict[str, Any]
@@ -100,19 +289,33 @@ class ProfileAnalysis(DBModel):
class ResearchResult(DBModel):
"""Research result model."""
id: Optional[UUID] = None
customer_id: UUID
user_id: UUID
created_at: Optional[datetime] = None
query: str
results: Dict[str, Any]
suggested_topics: List[Dict[str, Any]]
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):
"""Generated post model."""
id: Optional[UUID] = None
customer_id: UUID
user_id: UUID
created_at: Optional[datetime] = None
topic_id: Optional[UUID] = None
topic_title: str
@@ -120,7 +323,48 @@ class GeneratedPost(DBModel):
iterations: int = 0
writer_versions: List[str] = 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
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