File size: 6,478 Bytes
00a4363
 
 
 
070c931
 
 
 
 
 
 
 
 
 
 
 
 
00a4363
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0649f1b
 
00a4363
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
070c931
 
00a4363
 
 
 
 
070c931
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0649f1b
 
66de9c8
070c931
 
 
 
 
 
811ee7a
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import enum
import uuid
from datetime import datetime

from sqlalchemy import (
    JSON,
    Boolean,
    DateTime,
    Enum,
    Float,
    ForeignKey,
    Integer,
    String,
    UniqueConstraint,
    Uuid,
    true,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db import Base
from app.models.base import IdMixin, TimestampMixin


class AuthProvider(str, enum.Enum):
    GOOGLE = "google"
    EMAIL = "email"


class User(IdMixin, TimestampMixin, Base):
    """One auth provider per account for life, no linking (same rule as blog2video)."""

    __tablename__ = "users"

    email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
    name: Mapped[str | None] = mapped_column(String(200))
    avatar_url: Mapped[str | None] = mapped_column(String(1000))
    auth_provider: Mapped[AuthProvider] = mapped_column(Enum(AuthProvider, native_enum=False, length=16))
    google_id: Mapped[str | None] = mapped_column(String(64), unique=True)
    password_hash: Mapped[str | None] = mapped_column(String(200))
    token_version: Mapped[int] = mapped_column(Integer, default=0)
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
    deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    email_unsubscribed: Mapped[bool] = mapped_column(Boolean, default=False)
    welcome_email_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

    memberships: Mapped[list["WorkspaceMember"]] = relationship(back_populates="user")


class Workspace(IdMixin, TimestampMixin, Base):
    __tablename__ = "workspaces"

    name: Mapped[str] = mapped_column(String(200))
    owner_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"))
    training_opt_in: Mapped[bool] = mapped_column(Boolean, default=False)
    # Off: no report of this workspace can be shared by link, and existing links stop working.
    allow_public_links: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())

    members: Mapped[list["WorkspaceMember"]] = relationship(back_populates="workspace")


class WorkspaceMember(IdMixin, TimestampMixin, Base):
    __tablename__ = "workspace_members"
    __table_args__ = (UniqueConstraint("workspace_id", "user_id"),)

    workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"))
    user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id", ondelete="CASCADE"))
    role: Mapped[str] = mapped_column(String(20), default="owner")

    workspace: Mapped[Workspace] = relationship(back_populates="members")
    user: Mapped[User] = relationship(back_populates="memberships")


class Subscription(IdMixin, TimestampMixin, Base):
    __tablename__ = "subscriptions"

    workspace_id: Mapped[uuid.UUID] = mapped_column(
        Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"), unique=True
    )
    # A row of the plans table. Only kept while status is active/trialing/past_due (see plans.effective_plan).
    plan: Mapped[str] = mapped_column(String(20), ForeignKey("plans.id", onupdate="CASCADE"), default="free")
    status: Mapped[str] = mapped_column(String(20), default="active")
    provider: Mapped[str | None] = mapped_column(String(20))
    provider_customer_id: Mapped[str | None] = mapped_column(String(100))
    provider_subscription_id: Mapped[str | None] = mapped_column(String(100))
    current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    # Video allowance, enforced by us (see app/services/video_quota.py): +1 per video before we call blog2video,
    # -1 when it fails; video_plan/video_limit follow the effective plan and videos_used resets each period.
    video_plan: Mapped[str] = mapped_column(String(20), default="free", server_default="free")
    videos_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
    video_limit: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
    videos_period_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))


class PlanRecord(TimestampMixin, Base):
    """One row per plan: limits, price and pricing-page copy. Edit a row to change a plan without a deploy
    (app/services/plans.py reloads within a minute). Ids are fixed: Stripe prices and subscriptions use them."""

    __tablename__ = "plans"

    id: Mapped[str] = mapped_column(String(20), primary_key=True)  # free | writer | studio
    sort_order: Mapped[int] = mapped_column(Integer, default=0)  # cheapest first; upgrades go up this order
    name: Mapped[str] = mapped_column(String(50))
    tagline: Mapped[str] = mapped_column(String(200), default="")
    price_monthly_usd: Mapped[float] = mapped_column(Float, default=0)
    sources: Mapped[int] = mapped_column(Integer, default=1)
    indexed_posts: Mapped[int] = mapped_column(Integer, default=5)
    audio_minutes: Mapped[int] = mapped_column(Integer, default=0)
    videos: Mapped[int] = mapped_column(Integer, default=0)
    launch_kits: Mapped[int] = mapped_column(Integer, default=0)  # -1 = unlimited
    reports: Mapped[int] = mapped_column(Integer, default=0, server_default="0")  # per month, -1 = unlimited
    infographics: Mapped[int] = mapped_column(Integer, default=0, server_default="0")  # per month, -1 = unlimited
    audio_overviews: Mapped[int] = mapped_column(Integer, default=-1, server_default="-1")  # allowed (in total on Free); -1 = no cap
    voice_cloning: Mapped[bool] = mapped_column(Boolean, default=False)
    features: Mapped[list] = mapped_column(JSON, default=list)  # bullet points on the pricing page
    # Server defaults: 0006 seeds this table without these columns on a database 0001 built from the models.
    # False: `videos` is a lifetime total that never refills (Free). True: per month.
    videos_monthly: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
    video_limits: Mapped[dict | None] = mapped_column(JSON, default=dict)  # see plans.VIDEO_LIMITS


class BillingEvent(Base):
    """Stripe webhook events already handled, so a retried delivery never sends a second email."""

    __tablename__ = "billing_events"

    id: Mapped[str] = mapped_column(String(100), primary_key=True)  # Stripe event id (evt_...)
    type: Mapped[str] = mapped_column(String(80))
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))