diff --git a/app/migrations/versions/0c5d68f9e4b1_add_user_customers_and_product_slug.py b/app/migrations/versions/0c5d68f9e4b1_add_user_customers_and_product_slug.py deleted file mode 100644 index a5919f3..0000000 --- a/app/migrations/versions/0c5d68f9e4b1_add_user_customers_and_product_slug.py +++ /dev/null @@ -1,85 +0,0 @@ -"""add user customers and product slug - -Revision ID: 0c5d68f9e4b1 -Revises: a4b841bf907c -Create Date: 2026-04-07 11:40:00.000000 -""" - -from typing import Sequence, Union -from uuid import uuid4 - -from alembic import op -import sqlalchemy as sa - - -revision: str = "0c5d68f9e4b1" -down_revision: Union[str, None] = "a4b841bf907c" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column("products", sa.Column("slug", sa.String(), nullable=True)) - op.create_index(op.f("ix_products_slug"), "products", ["slug"], unique=True) - - op.create_table( - "user_customers", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("user_uuid", sa.UUID(), nullable=False), - sa.Column("customer_uuid", sa.UUID(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["customer_uuid"], ["customers.uuid"]), - sa.ForeignKeyConstraint(["user_uuid"], ["users.uuid"]), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_index( - "ix_user_customers_user_customer", - "user_customers", - ["user_uuid", "customer_uuid"], - unique=True, - ) - - connection = op.get_bind() - user_rows = connection.execute( - sa.text("SELECT uuid, customer_uuid FROM users WHERE customer_uuid IS NOT NULL") - ).fetchall() - for row in user_rows: - connection.execute( - sa.text( - """ - INSERT INTO user_customers (uuid, user_uuid, customer_uuid, created_at) - VALUES (:uuid, :user_uuid, :customer_uuid, now()) - ON CONFLICT DO NOTHING - """ - ), - { - "uuid": str(uuid4()), - "user_uuid": str(row.uuid), - "customer_uuid": str(row.customer_uuid), - }, - ) - - op.execute( - """ - UPDATE products - SET slug = lower(regexp_replace(name, '[^a-zA-Z0-9]+', '-', 'g')) - WHERE slug IS NULL - """ - ) - op.alter_column("customer_products", "end_date", existing_type=sa.DateTime(), nullable=True) - - op.execute( - """ - UPDATE products - SET slug = trim(both '-' from slug) - WHERE slug IS NOT NULL - """ - ) - - -def downgrade() -> None: - op.alter_column("customer_products", "end_date", existing_type=sa.DateTime(), nullable=False) - op.drop_index("ix_user_customers_user_customer", table_name="user_customers") - op.drop_table("user_customers") - op.drop_index(op.f("ix_products_slug"), table_name="products") - op.drop_column("products", "slug") diff --git a/app/migrations/versions/a4b841bf907c_initialize_models.py b/app/migrations/versions/a4b841bf907c_initialize_models.py deleted file mode 100644 index 11bba66..0000000 --- a/app/migrations/versions/a4b841bf907c_initialize_models.py +++ /dev/null @@ -1,274 +0,0 @@ -"""initialize models - -Revision ID: a4b841bf907c -Revises: -Create Date: 2025-04-22 15:23:17.189505 - -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = "a4b841bf907c" -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "contracts", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column( - "key", - sa.Enum( - "AGB_APP_ZUM_DOC_PATIENT", - "AGB_MEDIQUU_CONNECT", - "AGB_APP_ZUM_DOC", - "AGB_MEDIQUU_NETZMANAGER", - "AGB_MEDIQUU_CHAT", - "PRIVACY_CONCEPT", - "PRIVACY_CONCEPT_TASKS", - "AVV", - "AVV_TASKS", - "NDA", - "SUB", - "TOM", - name="contractkeyenum", - ), - nullable=False, - ), - sa.Column("version", sa.String(), nullable=False), - sa.Column("url", sa.String(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_table( - "customers", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("email", sa.String(), nullable=False), - sa.Column("website_url", sa.String(), nullable=True), - sa.Column("phone_number", sa.String(), nullable=True), - sa.Column("address", sa.String(), nullable=False), - sa.Column("house_number", sa.String(), nullable=False), - sa.Column("care_of", sa.String(), nullable=True), - sa.Column("postal_code", sa.String(), nullable=False), - sa.Column("city", sa.String(), nullable=False), - sa.Column("country", sa.String(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint("uuid"), - sa.UniqueConstraint("email"), - ) - op.create_table( - "products", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("image_url", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_table( - "messages", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("customer_uuid", sa.UUID(), nullable=False), - sa.Column("subject", sa.String(), nullable=False), - sa.Column("message", sa.Text(), nullable=False), - sa.Column( - "status", - sa.Enum("UNREAD", "READ", "ARCHIVED", name="messagestatusenum"), - nullable=False, - ), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ["customer_uuid"], - ["customers.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_table( - "product_contracts", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("product_uuid", sa.UUID(), nullable=False), - sa.Column("contract_uuid", sa.UUID(), nullable=False), - sa.ForeignKeyConstraint( - ["contract_uuid"], - ["contracts.uuid"], - ), - sa.ForeignKeyConstraint( - ["product_uuid"], - ["products.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_table( - "product_plans", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("product_uuid", sa.UUID(), nullable=False), - sa.Column( - "type", - sa.Enum("ONCE", "RECURRING", "LIFETIME", "TRIAL", name="plantypeenum"), - nullable=False, - ), - sa.Column("name", sa.String(), nullable=False), - sa.Column("cost_euro", sa.Numeric(), nullable=False), - sa.Column("recurring_month", sa.Integer(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ["product_uuid"], - ["products.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_table( - "users", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("customer_uuid", sa.UUID(), nullable=True), - sa.Column("name", sa.String(), nullable=False), - sa.Column("email", sa.String(), nullable=False), - sa.Column("role", sa.Enum("ADMIN", "NORMAL", name="roleenum"), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ["customer_uuid"], - ["customers.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - sa.UniqueConstraint("email"), - ) - op.create_table( - "vouchers", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("code", sa.String(), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("product_plan_uuid", sa.UUID(), nullable=False), - sa.Column("discount_percentage", sa.Numeric(), nullable=True), - sa.Column("discount_fixed_amount", sa.Numeric(), nullable=True), - sa.Column("valid_from", sa.DateTime(), nullable=False), - sa.Column("valid_until", sa.DateTime(), nullable=False), - sa.Column("max_redemptions", sa.Integer(), nullable=False), - sa.Column("redeemed_count", sa.Integer(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ["product_plan_uuid"], - ["product_plans.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - sa.UniqueConstraint("code"), - ) - op.create_table( - "customer_products", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("customer_uuid", sa.UUID(), nullable=False), - sa.Column("product_uuid", sa.UUID(), nullable=False), - sa.Column("product_plan_uuid", sa.UUID(), nullable=False), - sa.Column( - "status", - sa.Enum( - "TRIALING", - "ACTIVE", - "ACTIVATION", - "PAYMENT", - "SCHEDULED", - "CANCELED", - "EXPIRED", - "REFUNDED", - name="customerproductstatusenum", - ), - nullable=False, - ), - sa.Column("seats", sa.Integer(), nullable=True), - sa.Column("start_date", sa.DateTime(), nullable=False), - sa.Column("end_date", sa.DateTime(), nullable=False), - sa.Column("next_payment_date", sa.DateTime(), nullable=True), - sa.Column("voucher_uuid", sa.UUID(), nullable=True), - sa.Column("cancellation_date", sa.DateTime(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ["customer_uuid"], - ["customers.uuid"], - ), - sa.ForeignKeyConstraint( - ["product_plan_uuid"], - ["product_plans.uuid"], - ), - sa.ForeignKeyConstraint( - ["product_uuid"], - ["products.uuid"], - ), - sa.ForeignKeyConstraint( - ["voucher_uuid"], - ["vouchers.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_table( - "customer_product_contracts", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("product_customer_uuid", sa.UUID(), nullable=False), - sa.Column("contract_uuid", sa.UUID(), nullable=False), - sa.Column("accepted_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["contract_uuid"], - ["contracts.uuid"], - ), - sa.ForeignKeyConstraint( - ["product_customer_uuid"], - ["customer_products.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - ) - op.create_table( - "invoices", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("customer_uuid", sa.UUID(), nullable=False), - sa.Column("customer_product_uuid", sa.UUID(), nullable=False), - sa.Column("title", sa.String(), nullable=True), - sa.Column( - "status", - sa.Enum("PENDING", "PAID", "OVERDUE", name="invoicestatusenum"), - nullable=False, - ), - sa.Column("date", sa.DateTime(), nullable=False), - sa.Column("total_amount", sa.Numeric(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ["customer_product_uuid"], - ["customer_products.uuid"], - ), - sa.ForeignKeyConstraint( - ["customer_uuid"], - ["customers.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("invoices") - op.drop_table("customer_product_contracts") - op.drop_table("customer_products") - op.drop_table("vouchers") - op.drop_table("users") - op.drop_table("product_plans") - op.drop_table("product_contracts") - op.drop_table("messages") - op.drop_table("products") - op.drop_table("customers") - op.drop_table("contracts") - # ### end Alembic commands ### diff --git a/app/migrations/versions/b2e7a1c3d4f5_message_customer_product_links.py b/app/migrations/versions/b2e7a1c3d4f5_message_customer_product_links.py deleted file mode 100644 index d7030ea..0000000 --- a/app/migrations/versions/b2e7a1c3d4f5_message_customer_product_links.py +++ /dev/null @@ -1,40 +0,0 @@ -"""message customer product booking references - -Revision ID: b2e7a1c3d4f5 -Revises: 0c5d68f9e4b1 -Create Date: 2026-04-07 14:00:00.000000 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "b2e7a1c3d4f5" -down_revision: Union[str, None] = "0c5d68f9e4b1" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "message_customer_products", - sa.Column("message_uuid", sa.UUID(), nullable=False), - sa.Column("customer_product_uuid", sa.UUID(), nullable=False), - sa.ForeignKeyConstraint( - ["customer_product_uuid"], - ["customer_products.uuid"], - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["message_uuid"], - ["messages.uuid"], - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("message_uuid", "customer_product_uuid"), - ) - - -def downgrade() -> None: - op.drop_table("message_customer_products") diff --git a/app/migrations/versions/c3f8b2a1e6d7_app_variables_table.py b/app/migrations/versions/c3f8b2a1e6d7_app_variables_table.py deleted file mode 100644 index 485f11e..0000000 --- a/app/migrations/versions/c3f8b2a1e6d7_app_variables_table.py +++ /dev/null @@ -1,31 +0,0 @@ -"""app_variables table - -Revision ID: c3f8b2a1e6d7 -Revises: b2e7a1c3d4f5 -Create Date: 2026-04-14 12:00:00.000000 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "c3f8b2a1e6d7" -down_revision: Union[str, None] = "b2e7a1c3d4f5" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "app_variables", - sa.Column("key", sa.String(length=128), nullable=False), - sa.Column("value", sa.Text(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint("key"), - ) - - -def downgrade() -> None: - op.drop_table("app_variables") diff --git a/app/migrations/versions/cffecd06b2ab_initial_schema.py b/app/migrations/versions/cffecd06b2ab_initial_schema.py new file mode 100644 index 0000000..b757cf4 --- /dev/null +++ b/app/migrations/versions/cffecd06b2ab_initial_schema.py @@ -0,0 +1,231 @@ +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'cffecd06b2ab' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('app_variables', + sa.Column('key', sa.String(length=128), nullable=False), + sa.Column('value', sa.Text(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('key') + ) + op.create_table('contracts', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('key', sa.Enum('AGB_APP_ZUM_DOC_PATIENT', 'AGB_MEDIQUU_CONNECT', 'AGB_APP_ZUM_DOC', 'AGB_MEDIQUU_NETZMANAGER', 'AGB_MEDIQUU_CHAT', 'PRIVACY_CONCEPT', 'PRIVACY_CONCEPT_TASKS', 'AVV', 'AVV_TASKS', 'NDA', 'SUB', 'TOM', name='contractkeyenum'), nullable=False), + sa.Column('version', sa.String(), nullable=False), + sa.Column('url', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('customer_addresses', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('address', sa.String(), nullable=False), + sa.Column('house_number', sa.String(), nullable=False), + sa.Column('care_of', sa.String(), nullable=True), + sa.Column('postal_code', sa.String(), nullable=False), + sa.Column('city', sa.String(), nullable=False), + sa.Column('country', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('products', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('slug', sa.String(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('image_url', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_index(op.f('ix_products_slug'), 'products', ['slug'], unique=True) + op.create_table('customers', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('website_url', sa.String(), nullable=True), + sa.Column('phone_number', sa.String(), nullable=True), + sa.Column('registered_address_id', sa.UUID(), nullable=False), + sa.Column('invoice_address_id', sa.UUID(), nullable=True), + sa.Column('parent_customer_uuid', sa.UUID(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['invoice_address_id'], ['customer_addresses.uuid'], ), + sa.ForeignKeyConstraint(['parent_customer_uuid'], ['customers.uuid'], ), + sa.ForeignKeyConstraint(['registered_address_id'], ['customer_addresses.uuid'], ), + sa.PrimaryKeyConstraint('uuid'), + sa.UniqueConstraint('email') + ) + op.create_index(op.f('ix_customers_parent_customer_uuid'), 'customers', ['parent_customer_uuid'], unique=False) + op.create_table('product_contracts', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('product_uuid', sa.UUID(), nullable=False), + sa.Column('contract_uuid', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['contract_uuid'], ['contracts.uuid'], ), + sa.ForeignKeyConstraint(['product_uuid'], ['products.uuid'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('product_plans', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('product_uuid', sa.UUID(), nullable=False), + sa.Column('type', sa.Enum('ONCE', 'RECURRING', 'LIFETIME', 'TRIAL', name='plantypeenum'), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('cost_euro', sa.Numeric(), nullable=False), + sa.Column('recurring_month', sa.Integer(), nullable=True), + sa.Column('notice_period_months', sa.Integer(), nullable=False), + sa.Column('payment_period_months', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['product_uuid'], ['products.uuid'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('messages', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('customer_uuid', sa.UUID(), nullable=False), + sa.Column('subject', sa.String(), nullable=False), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('status', sa.Enum('UNREAD', 'READ', 'ARCHIVED', name='messagestatusenum'), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['customer_uuid'], ['customers.uuid'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('users', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('customer_uuid', sa.UUID(), nullable=True), + sa.Column('name', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('role', sa.Enum('ADMIN', 'NORMAL', name='roleenum'), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['customer_uuid'], ['customers.uuid'], ), + sa.PrimaryKeyConstraint('uuid'), + sa.UniqueConstraint('email') + ) + op.create_table('vouchers', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('code', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('product_plan_uuid', sa.UUID(), nullable=False), + sa.Column('discount_percentage', sa.Numeric(), nullable=True), + sa.Column('discount_fixed_amount', sa.Numeric(), nullable=True), + sa.Column('valid_from', sa.DateTime(), nullable=False), + sa.Column('valid_until', sa.DateTime(), nullable=False), + sa.Column('max_redemptions', sa.Integer(), nullable=False), + sa.Column('redeemed_count', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['product_plan_uuid'], ['product_plans.uuid'], ), + sa.PrimaryKeyConstraint('uuid'), + sa.UniqueConstraint('code') + ) + op.create_table('customer_products', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('customer_uuid', sa.UUID(), nullable=False), + sa.Column('product_uuid', sa.UUID(), nullable=False), + sa.Column('product_plan_uuid', sa.UUID(), nullable=False), + sa.Column('status', sa.Enum('active', 'trial', 'canceled', 'deactive', name='booking_status_enum'), nullable=False), + sa.Column('service_status', sa.Enum('provisioning', 'degraded', 'online', 'deprovisioning', name='service_status_enum'), nullable=True), + sa.Column('seats', sa.Integer(), nullable=True), + sa.Column('start_date', sa.DateTime(), nullable=False), + sa.Column('end_date', sa.DateTime(), nullable=True), + sa.Column('next_payment_date', sa.DateTime(), nullable=True), + sa.Column('voucher_uuid', sa.UUID(), nullable=True), + sa.Column('cancellation_date', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['customer_uuid'], ['customers.uuid'], ), + sa.ForeignKeyConstraint(['product_plan_uuid'], ['product_plans.uuid'], ), + sa.ForeignKeyConstraint(['product_uuid'], ['products.uuid'], ), + sa.ForeignKeyConstraint(['voucher_uuid'], ['vouchers.uuid'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('organization_invitations', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('customer_uuid', sa.UUID(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('status', sa.Enum('pending', 'accepted', 'revoked', name='invitationstatusenum'), nullable=False), + sa.Column('invited_by_user_uuid', sa.UUID(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['customer_uuid'], ['customers.uuid'], ), + sa.ForeignKeyConstraint(['invited_by_user_uuid'], ['users.uuid'], ), + sa.PrimaryKeyConstraint('uuid'), + sa.UniqueConstraint('token_hash') + ) + op.create_index('ix_organization_invitations_email_status', 'organization_invitations', ['email', 'status'], unique=False) + op.create_table('user_customers', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('user_uuid', sa.UUID(), nullable=False), + sa.Column('customer_uuid', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['customer_uuid'], ['customers.uuid'], ), + sa.ForeignKeyConstraint(['user_uuid'], ['users.uuid'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_index('ix_user_customers_user_customer', 'user_customers', ['user_uuid', 'customer_uuid'], unique=True) + op.create_table('customer_product_contracts', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('product_customer_uuid', sa.UUID(), nullable=False), + sa.Column('contract_uuid', sa.UUID(), nullable=False), + sa.Column('accepted_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['contract_uuid'], ['contracts.uuid'], ), + sa.ForeignKeyConstraint(['product_customer_uuid'], ['customer_products.uuid'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('invoices', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('customer_uuid', sa.UUID(), nullable=False), + sa.Column('customer_product_uuid', sa.UUID(), nullable=False), + sa.Column('title', sa.String(), nullable=True), + sa.Column('status', sa.Enum('PENDING', 'PAID', 'OVERDUE', name='invoicestatusenum'), nullable=False), + sa.Column('date', sa.DateTime(), nullable=False), + sa.Column('total_amount', sa.Numeric(), nullable=False), + sa.Column('sequence_number', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['customer_product_uuid'], ['customer_products.uuid'], ), + sa.ForeignKeyConstraint(['customer_uuid'], ['customers.uuid'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('message_customer_products', + sa.Column('message_uuid', sa.UUID(), nullable=False), + sa.Column('customer_product_uuid', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['customer_product_uuid'], ['customer_products.uuid'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['message_uuid'], ['messages.uuid'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('message_uuid', 'customer_product_uuid') + ) + + +def downgrade() -> None: + op.drop_table('message_customer_products') + op.drop_table('invoices') + op.drop_table('customer_product_contracts') + op.drop_index('ix_user_customers_user_customer', table_name='user_customers') + op.drop_table('user_customers') + op.drop_index('ix_organization_invitations_email_status', table_name='organization_invitations') + op.drop_table('organization_invitations') + op.drop_table('customer_products') + op.drop_table('vouchers') + op.drop_table('users') + op.drop_table('messages') + op.drop_table('product_plans') + op.drop_table('product_contracts') + op.drop_index(op.f('ix_customers_parent_customer_uuid'), table_name='customers') + op.drop_table('customers') + op.drop_index(op.f('ix_products_slug'), table_name='products') + op.drop_table('products') + op.drop_table('customer_addresses') + op.drop_table('contracts') + op.drop_table('app_variables') diff --git a/app/migrations/versions/d4e8f9a0b1c2_organization_invitations.py b/app/migrations/versions/d4e8f9a0b1c2_organization_invitations.py deleted file mode 100644 index 51c8718..0000000 --- a/app/migrations/versions/d4e8f9a0b1c2_organization_invitations.py +++ /dev/null @@ -1,60 +0,0 @@ -"""organization_invitations table - -Revision ID: d4e8f9a0b1c2 -Revises: c3f8b2a1e6d7 -Create Date: 2026-04-14 14:00:00.000000 -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - - -revision: str = "d4e8f9a0b1c2" -down_revision: Union[str, None] = "c3f8b2a1e6d7" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "organization_invitations", - sa.Column("uuid", sa.UUID(), nullable=False), - sa.Column("customer_uuid", sa.UUID(), nullable=False), - sa.Column("email", sa.String(length=255), nullable=False), - sa.Column("token_hash", sa.String(length=64), nullable=False), - sa.Column("expires_at", sa.DateTime(), nullable=False), - sa.Column( - "status", - sa.Enum("pending", "accepted", "revoked", name="invitationstatusenum"), - nullable=False, - ), - sa.Column("invited_by_user_uuid", sa.UUID(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["customer_uuid"], - ["customers.uuid"], - ), - sa.ForeignKeyConstraint( - ["invited_by_user_uuid"], - ["users.uuid"], - ), - sa.PrimaryKeyConstraint("uuid"), - sa.UniqueConstraint("token_hash"), - ) - op.create_index( - "ix_organization_invitations_email_status", - "organization_invitations", - ["email", "status"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index( - "ix_organization_invitations_email_status", - table_name="organization_invitations", - ) - op.drop_table("organization_invitations") - op.execute("DROP TYPE IF EXISTS invitationstatusenum") diff --git a/app/migrations/versions/k1l2m3n4o5p6_consolidated_customer_schema_evolution.py b/app/migrations/versions/k1l2m3n4o5p6_consolidated_customer_schema_evolution.py deleted file mode 100644 index 292a79d..0000000 --- a/app/migrations/versions/k1l2m3n4o5p6_consolidated_customer_schema_evolution.py +++ /dev/null @@ -1,324 +0,0 @@ -"""Consolidated: booking/service status, addresses/parent org, plans/invoices/trial. - -Revision ID: k1l2m3n4o5p6 -Revises: d4e8f9a0b1c2 -Create Date: 2026-04-16 12:00:00.000000 -""" - -from __future__ import annotations - -import uuid -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op -from sqlalchemy import text -from sqlalchemy.dialects import postgresql - -revision: str = "k1l2m3n4o5p6" -down_revision: Union[str, None] = "d4e8f9a0b1c2" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def _upgrade_booking_and_service_status() -> None: - conn = op.get_bind() - - conn.execute( - text( - """ - CREATE TYPE booking_status_enum AS ENUM ( - 'active', 'demo', 'canceled', 'deactive' - ) - """ - ) - ) - conn.execute( - text( - """ - CREATE TYPE service_status_enum AS ENUM ( - 'provisioning', 'degraded', 'online', 'deprovisioning' - ) - """ - ) - ) - - op.add_column( - "customer_products", - sa.Column( - "service_status", - sa.Enum( - "provisioning", - "degraded", - "online", - "deprovisioning", - name="service_status_enum", - create_type=False, - ), - nullable=True, - ), - ) - - op.add_column( - "customer_products", - sa.Column( - "status_booking", - sa.Enum( - "active", - "demo", - "canceled", - "deactive", - name="booking_status_enum", - create_type=False, - ), - nullable=True, - ), - ) - - conn.execute( - text( - """ - UPDATE customer_products - SET status_booking = CASE status::text - WHEN 'TRIALING' THEN 'demo'::booking_status_enum - WHEN 'ACTIVE' THEN 'active'::booking_status_enum - WHEN 'ACTIVATION' THEN 'active'::booking_status_enum - WHEN 'PAYMENT' THEN 'active'::booking_status_enum - WHEN 'SCHEDULED' THEN 'canceled'::booking_status_enum - WHEN 'CANCELED' THEN 'canceled'::booking_status_enum - WHEN 'EXPIRED' THEN 'deactive'::booking_status_enum - WHEN 'REFUNDED' THEN 'deactive'::booking_status_enum - ELSE 'active'::booking_status_enum - END - """ - ) - ) - - conn.execute( - text( - """ - UPDATE customer_products - SET service_status = CASE status::text - WHEN 'ACTIVATION' THEN 'provisioning'::service_status_enum - WHEN 'PAYMENT' THEN 'provisioning'::service_status_enum - WHEN 'ACTIVE' THEN 'online'::service_status_enum - WHEN 'TRIALING' THEN 'online'::service_status_enum - WHEN 'SCHEDULED' THEN 'deprovisioning'::service_status_enum - ELSE NULL::service_status_enum - END - """ - ) - ) - - op.drop_column("customer_products", "status") - conn.execute(text("DROP TYPE customerproductstatusenum")) - op.execute( - "ALTER TABLE customer_products RENAME COLUMN status_booking TO status" - ) - conn.execute(text("ALTER TABLE customer_products ALTER COLUMN status SET NOT NULL")) - - -def _upgrade_customer_addresses_and_parent_org() -> None: - op.create_table( - "customer_addresses", - sa.Column("uuid", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("address", sa.String(), nullable=False), - sa.Column("house_number", sa.String(), nullable=False), - sa.Column("care_of", sa.String(), nullable=True), - sa.Column("postal_code", sa.String(), nullable=False), - sa.Column("city", sa.String(), nullable=False), - sa.Column("country", sa.String(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint("uuid"), - ) - - bind = op.get_bind() - rows = bind.execute( - sa.text( - """ - SELECT uuid, address, house_number, care_of, postal_code, city, country, - created_at, updated_at - FROM customers - """ - ) - ).fetchall() - - op.add_column( - "customers", - sa.Column( - "registered_address_id", - postgresql.UUID(as_uuid=True), - nullable=True, - ), - ) - op.add_column( - "customers", - sa.Column( - "invoice_address_id", - postgresql.UUID(as_uuid=True), - nullable=True, - ), - ) - op.add_column( - "customers", - sa.Column( - "parent_customer_uuid", - postgresql.UUID(as_uuid=True), - nullable=True, - ), - ) - - for row in rows: - addr_id = uuid.uuid4() - bind.execute( - sa.text( - """ - INSERT INTO customer_addresses ( - uuid, address, house_number, care_of, postal_code, city, country, - created_at, updated_at - ) VALUES ( - :uuid, :address, :house_number, :care_of, :postal_code, :city, :country, - :created_at, :updated_at - ) - """ - ), - { - "uuid": addr_id, - "address": row.address, - "house_number": row.house_number, - "care_of": row.care_of, - "postal_code": row.postal_code, - "city": row.city, - "country": row.country, - "created_at": row.created_at, - "updated_at": row.updated_at, - }, - ) - bind.execute( - sa.text( - "UPDATE customers SET registered_address_id = :aid WHERE uuid = :cid" - ), - {"aid": addr_id, "cid": row.uuid}, - ) - - op.alter_column( - "customers", - "registered_address_id", - existing_type=postgresql.UUID(as_uuid=True), - nullable=False, - ) - - op.create_foreign_key( - "fk_customers_registered_address_id", - "customers", - "customer_addresses", - ["registered_address_id"], - ["uuid"], - ) - op.create_foreign_key( - "fk_customers_invoice_address_id", - "customers", - "customer_addresses", - ["invoice_address_id"], - ["uuid"], - ) - op.create_foreign_key( - "fk_customers_parent_customer_uuid", - "customers", - "customers", - ["parent_customer_uuid"], - ["uuid"], - ) - op.create_index( - "ix_customers_parent_customer_uuid", - "customers", - ["parent_customer_uuid"], - ) - - op.drop_column("customers", "address") - op.drop_column("customers", "house_number") - op.drop_column("customers", "care_of") - op.drop_column("customers", "postal_code") - op.drop_column("customers", "city") - op.drop_column("customers", "country") - - -def _upgrade_product_plans_invoices_trial() -> None: - op.add_column( - "product_plans", - sa.Column( - "notice_period_months", - sa.Integer(), - nullable=False, - server_default="1", - ), - ) - op.add_column( - "product_plans", - sa.Column( - "payment_period_months", - sa.Integer(), - nullable=False, - server_default="1", - ), - ) - op.alter_column( - "product_plans", - "notice_period_months", - server_default=None, - ) - op.alter_column( - "product_plans", - "payment_period_months", - server_default=None, - ) - - op.add_column( - "invoices", - sa.Column("sequence_number", sa.Integer(), nullable=True), - ) - op.execute( - """ - WITH ranked AS ( - SELECT uuid, - ROW_NUMBER() OVER ( - PARTITION BY customer_product_uuid - ORDER BY date ASC, created_at ASC - ) AS rn - FROM invoices - ) - UPDATE invoices AS i - SET sequence_number = ranked.rn - FROM ranked - WHERE i.uuid = ranked.uuid - """ - ) - op.alter_column( - "invoices", - "sequence_number", - nullable=False, - ) - op.create_unique_constraint( - "uq_invoices_customer_product_sequence", - "invoices", - ["customer_product_uuid", "sequence_number"], - ) - - with op.get_context().autocommit_block(): - op.execute("ALTER TYPE booking_status_enum ADD VALUE IF NOT EXISTS 'trial'") - - op.execute( - "UPDATE customer_products SET status = 'trial'::booking_status_enum " - "WHERE status = 'demo'::booking_status_enum" - ) - - -def upgrade() -> None: - _upgrade_booking_and_service_status() - _upgrade_customer_addresses_and_parent_org() - _upgrade_product_plans_invoices_trial() - - -def downgrade() -> None: - raise NotImplementedError diff --git a/app/namespaces/customer/customer.py b/app/namespaces/customer/customer.py index 61932bf..fd5b1bd 100644 --- a/app/namespaces/customer/customer.py +++ b/app/namespaces/customer/customer.py @@ -77,6 +77,11 @@ async def create( user: User = Depends(get_user), session=Depends(get_database), ): + if oidc_user.impersonation_mode: + raise HTTPException( + 403, + detail="Creating an organization is not available during impersonation.", + ) if user.customer: raise HTTPException(400, detail="User already created a Customer.") diff --git a/app/namespaces/customer/customer_invitations.py b/app/namespaces/customer/customer_invitations.py index 8010098..2ee2c65 100644 --- a/app/namespaces/customer/customer_invitations.py +++ b/app/namespaces/customer/customer_invitations.py @@ -288,6 +288,12 @@ async def accept_invitation( invitation_uuid=data.invitation_uuid, ) + if oidc_user.impersonation_mode: + raise HTTPException( + 403, + detail="Accepting invitations is not available during impersonation.", + ) + existing = ( session.query(UserCustomer) .filter_by(user_uuid=user.uuid, customer_uuid=inv.customer_uuid) diff --git a/app/namespaces/team/admin.py b/app/namespaces/team/admin.py index d41795f..fd2139d 100644 --- a/app/namespaces/team/admin.py +++ b/app/namespaces/team/admin.py @@ -15,10 +15,7 @@ from models.voucher import Voucher from namespaces.team.app_variables import router as app_variables_router from utils.database.session import get_database -from utils.keycloak_impersonation import ( - fetch_keycloak_impersonation_redirect_url, - pick_organization_member_for_impersonation, -) +from utils.keycloak_impersonation import pick_organization_member_for_impersonation from utils.security.token import UserInfo, get_team_user router = APIRouter(prefix="/admin", tags=["Admin"]) @@ -160,7 +157,8 @@ class CustomerDetail(BaseModel): class CustomerImpersonationResponse(BaseModel): - redirect_url: str + customer_uuid: str + organization_name: str | None target_user_email: str @@ -514,18 +512,9 @@ async def impersonate_customer_organization( detail="This organization has no users to impersonate.", ) - redirect_url = fetch_keycloak_impersonation_redirect_url(str(target_user.uuid)) - if not redirect_url: - raise HTTPException( - 503, - detail=( - "Impersonation is unavailable. Ensure IAM admin client credentials " - "are configured and the service account may impersonate users." - ), - ) - return CustomerImpersonationResponse( - redirect_url=redirect_url, + customer_uuid=str(customer.uuid), + organization_name=customer.name, target_user_email=target_user.email, ) diff --git a/app/utils/keycloak_impersonation.py b/app/utils/keycloak_impersonation.py index 32715c7..46e12c6 100644 --- a/app/utils/keycloak_impersonation.py +++ b/app/utils/keycloak_impersonation.py @@ -1,22 +1,8 @@ from __future__ import annotations -import json -import logging -from collections.abc import Iterator - -from keycloak import KeycloakAdmin -from keycloak.exceptions import KeycloakError - from models.customer import Customer from models.static import RoleEnum from models.user import User -from utils.config import main_realm_name, settings -from utils.keycloak_iam import ( - build_iam_keycloak_admin_from_settings, - development_master_keycloak_admin, -) - -logger = logging.getLogger(__name__) def pick_organization_member_for_impersonation(customer: Customer) -> User | None: @@ -26,75 +12,3 @@ def pick_organization_member_for_impersonation(customer: Customer) -> User | Non admins = [u for u in members if u.role == RoleEnum.ADMIN] pool = admins if admins else members return sorted(pool, key=lambda u: u.email.lower())[0] - - -def _iter_keycloak_admins_for_impersonation() -> Iterator[KeycloakAdmin]: - iam_admin = build_iam_keycloak_admin_from_settings(settings) - if iam_admin is not None: - yield iam_admin - master_admin = development_master_keycloak_admin() - if master_admin is not None: - yield master_admin - - -def _redirect_url_from_impersonation_response(response) -> str | None: - if response.status_code in (200, 201): - try: - payload = response.json() - except (json.JSONDecodeError, ValueError): - payload = None - if isinstance(payload, dict): - redirect = payload.get("redirect") - if isinstance(redirect, str) and redirect.strip(): - return redirect.strip() - if response.status_code in (302, 303, 307): - location = response.headers.get("Location") - if location: - return location - return None - - -def fetch_keycloak_impersonation_redirect_url(keycloak_user_id: str) -> str | None: - realm = (main_realm_name or "").strip() - if not realm: - logger.warning("Customer realm (OIDC_MAIN_ISSUER_URL) missing; cannot impersonate.") - return None - path = f"admin/realms/{realm}/users/{keycloak_user_id}/impersonation" - - admins = list(_iter_keycloak_admins_for_impersonation()) - if not admins: - logger.warning( - "No Keycloak admin credentials available. Configure IAM_CLIENT_SECRET with a " - "service account that may impersonate users, or in development set " - "KEYCLOAK_ADMIN_PASSWORD (and optionally KEYCLOAK_ADMIN_USER) for the master admin." - ) - return None - - last_status: int | None = None - for admin in admins: - try: - response = admin.connection.raw_post(path, data="{}") - except KeycloakError: - logger.exception("Keycloak impersonation request failed for user %s", keycloak_user_id) - continue - last_status = response.status_code - url = _redirect_url_from_impersonation_response(response) - if url: - return url - if response.status_code == 403: - logger.info( - "Impersonation returned 403 with one admin credential; retrying with another if configured." - ) - continue - logger.warning( - "Unexpected Keycloak impersonation response status=%s body=%s", - response.status_code, - (response.text or "")[:500], - ) - - if last_status == 403: - logger.warning( - "Impersonation forbidden (403). Grant the IAM service account the realm-management " - "impersonation role on the customer realm, or use master admin credentials in development." - ) - return None diff --git a/app/utils/security/token.py b/app/utils/security/token.py index f0e0b1c..d186495 100644 --- a/app/utils/security/token.py +++ b/app/utils/security/token.py @@ -25,9 +25,12 @@ settings, ) from utils.database.session import get_database +from utils.keycloak_impersonation import pick_organization_member_for_impersonation bearer_scheme = HTTPBearer() +IMPERSONATION_ORGANIZATION_HEADER = "X-Helpwave-Impersonate-Organization" + class OrganizationRef(BaseModel): id: str | None = None @@ -47,6 +50,7 @@ class UserInfo(BaseModel): lastname: str | None = None organizations: list[OrganizationRef] | None = None + impersonation_mode: bool = False class InternalPrincipal(BaseModel): @@ -385,6 +389,67 @@ def verify_internal_service( return verify_internal_token(credentials.credentials) +def _try_team_impersonation_session( + request: Request, + token: str, + session, +) -> tuple[UserInfo, User] | None: + raw = (request.headers.get(IMPERSONATION_ORGANIZATION_HEADER) or "").strip() + if not raw: + return None + + team_info = verify_team(token) + _ensure_verified_email(team_info) + + customer_uuid_val = _parse_uuid(raw) + if not customer_uuid_val: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid impersonation organization.", + ) + + customer = session.query(Customer).filter_by(uuid=customer_uuid_val).first() + if not customer: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organization not found.") + + target_user = pick_organization_member_for_impersonation(customer) + if not target_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This organization has no users to impersonate.", + ) + + user = session.query(User).filter_by(uuid=target_user.uuid).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") + + link = ( + session.query(UserCustomer) + .filter_by(user_uuid=user.uuid, customer_uuid=customer_uuid_val) + .first() + ) + if not link: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Membership missing for impersonation target.", + ) + + display_name = user.name or user.email or str(user.uuid) + user_info = UserInfo( + uuid=user.uuid, + username=user.email or str(user.uuid), + email=user.email, + email_verified=True, + fullname=display_name, + organizations=[ + OrganizationRef(id=str(customer.uuid), name=customer.name), + ], + impersonation_mode=True, + ) + + return user_info, user + + def get_user( request: Request, credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), @@ -392,25 +457,29 @@ def get_user( ) -> User: token = credentials.credentials - user_info = verify(token) - _ensure_verified_email(user_info) + impersonation = _try_team_impersonation_session(request, token, session) + if impersonation: + user_info, user = impersonation + else: + user_info = verify(token) + _ensure_verified_email(user_info) - user = session.query(User).filter_by(uuid=user_info.uuid).first() + user = session.query(User).filter_by(uuid=user_info.uuid).first() - if not user: - user = User(uuid=user_info.uuid, name=user_info.fullname, email=user_info.email) - session.add(user) - try: - session.commit() - session.refresh(user) - except IntegrityError: - session.rollback() - user = session.query(User).filter_by(uuid=user_info.uuid).first() - if not user: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="User record could not be created or loaded.", - ) from None + if not user: + user = User(uuid=user_info.uuid, name=user_info.fullname, email=user_info.email) + session.add(user) + try: + session.commit() + session.refresh(user) + except IntegrityError: + session.rollback() + user = session.query(User).filter_by(uuid=user_info.uuid).first() + if not user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="User record could not be created or loaded.", + ) from None token_organization_uuids = _token_organization_uuids(user_info) _sync_user_customers_from_token( @@ -458,20 +527,32 @@ def get_user( def require_main_oidc( + request: Request, credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), + session=Depends(get_database), ) -> UserInfo: token = credentials.credentials + impersonation = _try_team_impersonation_session(request, token, session) + if impersonation: + user_info, _user = impersonation + _ensure_verified_email(user_info) + return user_info user_info = verify(token) - _ensure_verified_email(user_info) - return user_info def require_main_oidc_token( + request: Request, credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), + session=Depends(get_database), ) -> UserInfo: - return verify(credentials.credentials) + token = credentials.credentials + impersonation = _try_team_impersonation_session(request, token, session) + if impersonation: + user_info, _user = impersonation + return user_info + return verify(token) def get_team_user( diff --git a/flake.nix b/flake.nix index 70e293f..c4bb1cc 100644 --- a/flake.nix +++ b/flake.nix @@ -8,6 +8,7 @@ outputs = { self, nixpkgs }: let + flakeRevision = self.rev or self.dirtyRev or "unknown"; system = "x86_64-linux"; pkgs = import nixpkgs { inherit system; }; dockerComposeFile = "docker-compose.dev.yml"; @@ -76,9 +77,6 @@ export IAM_REALM="customer" export IAM_CLIENT_ID="customer-internal" export IAM_CLIENT_SECRET="customer-internal" - export KEYCLOAK_ADMIN_USER="admin" - export KEYCLOAK_ADMIN="admin" - export KEYCLOAK_ADMIN_PASSWORD="admin" export S3_ENDPOINT="http://localhost:9000" export S3_ACCESS_KEY="minioadmin" @@ -94,7 +92,7 @@ export LD_LIBRARY_PATH="${libPath}:$LD_LIBRARY_PATH" - echo ">>> helpwave customer dev shell ready..." + echo ">>> helpwave customer dev shell ready (flake ${flakeRevision})..." if [ ! -d "$PROJECT_ROOT/app/venv" ]; then ${python}/bin/python -m venv "$PROJECT_ROOT/app/venv" diff --git a/web/locales/de-DE.arb b/web/locales/de-DE.arb index 474a0ed..90476ec 100644 --- a/web/locales/de-DE.arb +++ b/web/locales/de-DE.arb @@ -8,6 +8,14 @@ "adminAppVariablesSave": "Speichern", "adminAppVariablesSaveFailed": "Speichern fehlgeschlagen.", "brandAdminLabel": "ADMIN", + "impersonationBannerTitle": "Impersonationsmodus", + "impersonationBannerBody": "Ansicht als {organizationName}. Sie nutzen die Team-Sitzung; Aktionen nur in IAM bleiben deaktiviert.", + "@impersonationBannerBody": { + "placeholders": { + "organizationName": {} + } + }, + "impersonationExit": "Zurück zur Administration", "organization": "Organisation", "selectOrganization": "Organisation auswahlen", "navOverview": "Übersicht", diff --git a/web/locales/en-US.arb b/web/locales/en-US.arb index f314f77..16f39c0 100644 --- a/web/locales/en-US.arb +++ b/web/locales/en-US.arb @@ -8,6 +8,14 @@ "adminAppVariablesSave": "Save", "adminAppVariablesSaveFailed": "Save failed.", "brandAdminLabel": "ADMIN", + "impersonationBannerTitle": "Impersonation mode", + "impersonationBannerBody": "Viewing as {organizationName}. You are using the team console session; IAM-only actions stay disabled.", + "@impersonationBannerBody": { + "placeholders": { + "organizationName": {} + } + }, + "impersonationExit": "Exit to admin", "organization": "Organization", "selectOrganization": "Select organization", "navOverview": "Overview", diff --git a/web/src/api/client.js b/web/src/api/client.js index 9b9bbba..e5890e7 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -1,4 +1,8 @@ import { apiBaseUrl } from '../config/buildEnv' +import { + IMPERSONATION_ORGANIZATION_HEADER, + readCustomerImpersonationPayload, +} from '../utils/customerImpersonation.js' const reauthTriggerStatuses = new Set([401]) @@ -55,6 +59,14 @@ function isCustomerApiAbsoluteUrl(url) { } } +function customerImpersonationHeaders() { + const payload = readCustomerImpersonationPayload() + if (!payload?.customerUuid) { + return {} + } + return { [IMPERSONATION_ORGANIZATION_HEADER]: payload.customerUuid } +} + export async function fetchWithCustomerRetry(url, init) { if (!isCustomerApiAbsoluteUrl(url)) { return fetch(url, init) @@ -103,6 +115,7 @@ export async function apiRequest( headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(isCustomerApiPath(path) ? customerImpersonationHeaders() : {}), ...headers, }, ...(body ? { body: JSON.stringify(body) } : {}), diff --git a/web/src/components/layout/RealmShell.jsx b/web/src/components/layout/RealmShell.jsx index c95a4ab..fb92882 100644 --- a/web/src/components/layout/RealmShell.jsx +++ b/web/src/components/layout/RealmShell.jsx @@ -10,9 +10,11 @@ export function RealmShell({ organizations, selectedOrganizationId, onSelectOrganization, + organizationSelectionReadOnly = false, outletContext, userProfile, showSidePanel = true, + impersonationBanner = null, }) { return (
{t('organization')}
- + {organizationSelectionReadOnly ? ( +{t('impersonationBannerTitle')}
++ {t('impersonationBannerBody', { + organizationName: + impersonationSnapshotRef.current?.organizationName || + selectedOrganization?.name || + '—', + })} +
+