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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user