feat: production deployment - full update with providers, admin, glossaries, pricing, tests

Major changes across backend, frontend, infrastructure:
- Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud)
- Admin panel: user management, pricing, settings
- Glossary system with CSV import/export
- Subscription and tier quota management
- Security hardening (rate limiting, API key auth, path traversal fixes)
- Docker compose for dev, prod, and IONOS deployment
- Alembic migrations for new tables
- Frontend: dashboard, pricing page, landing page, i18n (en/fr)
- Test suite and verification scripts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-04-25 15:01:47 +02:00
parent 2ba4fedfc8
commit 26bd096a06
1178 changed files with 136435 additions and 3047 deletions

View File

@@ -0,0 +1,77 @@
"""Add tier, daily_translation_count, and hashed_password fields
Revision ID: 002_add_tier_daily_count
Revises: 001_initial
Create Date: 2026-02-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "002_add_tier_daily_count"
down_revision: Union[str, None] = "001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
dialect = conn.dialect.name
op.add_column(
"users",
sa.Column("tier", sa.String(10), nullable=True, server_default="free"),
)
op.add_column(
"users",
sa.Column(
"daily_translation_count",
sa.Integer(),
nullable=True,
server_default=sa.text("0"),
),
)
op.add_column("users", sa.Column("hashed_password", sa.String(255), nullable=True))
op.execute(
sa.text(
"UPDATE users SET hashed_password = password_hash WHERE hashed_password IS NULL"
)
)
op.execute(
sa.text(
"UPDATE users SET tier = CASE "
"WHEN plan IN ('pro', 'business', 'enterprise') THEN 'pro' "
"ELSE 'free' END "
"WHERE tier IS NULL OR tier = 'free'"
)
)
op.execute(
sa.text(
"UPDATE users SET daily_translation_count = 0 WHERE daily_translation_count IS NULL"
)
)
# SQLite does not support ALTER COLUMN — skip nullable enforcement there
if dialect != "sqlite":
op.alter_column("users", "tier", nullable=False)
op.alter_column("users", "daily_translation_count", nullable=False)
op.create_check_constraint("ck_users_tier", "users", "tier IN ('free', 'pro')")
def downgrade() -> None:
conn = op.get_bind()
dialect = conn.dialect.name
if dialect != "sqlite":
op.drop_constraint("ck_users_tier", "users", type_="check")
op.drop_column("users", "hashed_password")
op.drop_column("users", "daily_translation_count")
op.drop_column("users", "tier")

View File

@@ -0,0 +1,29 @@
"""Add revoked_at column to api_keys table
Revision ID: 003_add_revoked_at
Revises: 002_add_tier_daily_count
Create Date: 2026-02-22
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '003_add_revoked_at'
down_revision = '002_add_tier_daily_count'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add revoked_at column to api_keys table for tracking key revocation timestamps."""
op.add_column(
'api_keys',
sa.Column('revoked_at', sa.DateTime(), nullable=True)
)
def downgrade() -> None:
"""Remove revoked_at column from api_keys table."""
op.drop_column('api_keys', 'revoked_at')

View File

@@ -0,0 +1,49 @@
"""Rename legacy account_tier column to tier when tier is missing
Some SQLite databases were stamped at head but still use account_tier from an
older branch; the ORM expects column name tier (migration 002).
Revision ID: 004_rename_account_tier
Revises: 5206607942a2
Create Date: 2026-04-13
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "004_rename_account_tier"
down_revision: Union[str, None] = "5206607942a2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
insp = sa.inspect(conn)
cols = {c["name"] for c in insp.get_columns("users")}
if "account_tier" in cols and "tier" not in cols:
op.alter_column(
"users",
"account_tier",
new_column_name="tier",
existing_type=sa.String(10),
existing_nullable=True,
)
def downgrade() -> None:
conn = op.get_bind()
insp = sa.inspect(conn)
cols = {c["name"] for c in insp.get_columns("users")}
if "tier" in cols and "account_tier" not in cols:
op.alter_column(
"users",
"tier",
new_column_name="account_tier",
existing_type=sa.String(10),
existing_nullable=True,
)

View File

@@ -0,0 +1,42 @@
"""
Alembic migration script template
add_custom_prompts_table
Revision ID: 5206607942a2
Revises: cb71a958ad92
Create Date: 2026-02-22 20:07:33.224269
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "5206607942a2"
down_revision: Union[str, None] = "cb71a958ad92"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"custom_prompts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_custom_prompts_user_id", "custom_prompts", ["user_id"], unique=False
)
def downgrade() -> None:
op.drop_index("ix_custom_prompts_user_id", table_name="custom_prompts")
op.drop_table("custom_prompts")

View File

@@ -0,0 +1,56 @@
"""
Alembic migration script template
add_glossaries_tables
Revision ID: cb71a958ad92
Revises: 003_add_revoked_at
Create Date: 2026-02-22 15:13:10.685242
Story 3.9: Glossaires - Endpoint CRUD
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "cb71a958ad92"
down_revision: Union[str, None] = "003_add_revoked_at"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"glossaries",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_glossaries_user_id", "glossaries", ["user_id"], unique=False)
op.create_table(
"glossary_terms",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("glossary_id", sa.String(length=36), nullable=False),
sa.Column("source", sa.String(length=500), nullable=False),
sa.Column("target", sa.String(length=500), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["glossary_id"], ["glossaries.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_glossary_terms_glossary_id", "glossary_terms", ["glossary_id"], unique=False
)
def downgrade() -> None:
op.drop_index("ix_glossary_terms_glossary_id", table_name="glossary_terms")
op.drop_table("glossary_terms")
op.drop_index("ix_glossaries_user_id", table_name="glossaries")
op.drop_table("glossaries")