Files
Onyva-Postling/src/database/models.py

418 lines
12 KiB
Python

"""Pydantic models for database entities."""
from datetime import datetime, date, timezone
from enum import Enum
from typing import Optional, Dict, Any, List
from uuid import UUID
from pydantic import BaseModel, Field, ConfigDict
class DBModel(BaseModel):
"""Base model for database entities with extra fields ignored."""
model_config = ConfigDict(extra='ignore')
# ==================== 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
# 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 TelegramAccount(DBModel):
"""Telegram account connection for bot access."""
id: Optional[UUID] = None
user_id: UUID
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
telegram_user_id: str
telegram_username: Optional[str] = None
telegram_first_name: Optional[str] = None
telegram_chat_id: str
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
user_id: UUID
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
name: str
description: Optional[str] = None
identifying_hashtags: List[str] = Field(default_factory=list)
identifying_keywords: List[str] = Field(default_factory=list)
semantic_properties: Dict[str, Any] = Field(default_factory=dict)
analysis: Optional[Dict[str, Any]] = None
analysis_generated_at: Optional[datetime] = None
analyzed_post_count: int = 0
is_active: bool = True
strategy_weight: float = 0.5
class LinkedInProfile(DBModel):
"""LinkedIn profile model."""
id: Optional[UUID] = None
user_id: UUID
scraped_at: Optional[datetime] = None
profile_data: Dict[str, Any]
name: Optional[str] = None
headline: Optional[str] = None
summary: Optional[str] = None
location: Optional[str] = None
industry: Optional[str] = None
class LinkedInPost(DBModel):
"""LinkedIn post model."""
id: Optional[UUID] = None
user_id: UUID
scraped_at: Optional[datetime] = None
post_url: Optional[str] = None
post_text: str
post_date: Optional[datetime] = None
likes: int = 0
comments: int = 0
shares: int = 0
raw_data: Optional[Dict[str, Any]] = None
# Post type classification fields
post_type_id: Optional[UUID] = None
classification_method: Optional[str] = None # 'hashtag', 'keyword', 'semantic'
classification_confidence: Optional[float] = None
class Topic(DBModel):
"""Topic model."""
id: Optional[UUID] = None
user_id: UUID
created_at: Optional[datetime] = None
title: str
description: Optional[str] = None
category: Optional[str] = None
extracted_from_post_id: Optional[UUID] = None
extraction_confidence: Optional[float] = None
is_used: bool = False
used_at: Optional[datetime] = None
target_post_type_id: Optional[UUID] = None
class ProfileAnalysis(DBModel):
"""Profile analysis model."""
id: Optional[UUID] = None
user_id: UUID
created_at: Optional[datetime] = None
writing_style: Dict[str, Any]
tone_analysis: Dict[str, Any]
topic_patterns: Dict[str, Any]
audience_insights: Dict[str, Any]
full_analysis: Dict[str, Any]
class ResearchResult(DBModel):
"""Research result model."""
id: Optional[UUID] = None
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
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 MediaItem(BaseModel):
"""Single media item (image or video) for a post."""
type: str # "image" | "video"
url: str
order: int
content_type: str # MIME type (e.g., 'image/jpeg', 'video/mp4')
uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
metadata: Optional[Dict[str, Any]] = None
class GeneratedPost(DBModel):
"""Generated post model."""
id: Optional[UUID] = None
user_id: UUID
created_at: Optional[datetime] = None
topic_id: Optional[UUID] = None
topic_title: str
post_content: str
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, ready, scheduled, published, rejected
approved_at: Optional[datetime] = None
published_at: Optional[datetime] = None
post_type_id: Optional[UUID] = None
# Media (multi-media support)
media_items: List[MediaItem] = Field(default_factory=list)
# DEPRECATED: Image (kept for backward compatibility)
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
@property
def has_media(self) -> bool:
"""Check if post has any media items."""
return len(self.media_items) > 0
# ==================== 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
daily_token_limit: Optional[int] = None # None = unlimited
# 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 LicenseKeyOffer(DBModel):
"""MOCO offer created for a license key."""
id: Optional[UUID] = None
license_key_id: UUID
moco_offer_id: int
moco_offer_identifier: Optional[str] = None
moco_offer_url: Optional[str] = None
offer_title: Optional[str] = None
company_name: Optional[str] = None
price: Optional[float] = None
payment_frequency: Optional[str] = None
status: str = "draft"
created_at: Optional[datetime] = None
class CompanyDailyQuota(DBModel):
"""Daily usage quota for a company."""
id: Optional[UUID] = None
company_id: UUID
date: date
tokens_used: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None