diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 5966f3da..80dd2781 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -1,6 +1,8 @@ name: App Build on: push: + branches: + - dev paths: - "**" pull_request: @@ -14,3 +16,14 @@ jobs: - name: Build Docker Image run: docker build -t learnhouse . working-directory: . + - name: Repository Dispatch + uses: peter-evans/repository-dispatch@v2 + with: + token: ${{ secrets.PAT_TOKEN }} + repository: learnhouse/images + event-type: build-images + client-payload: | + { + "ref": "${{ github.sha }}", + "branch": "${{ github.head_ref || github.ref_name }}" + } diff --git a/apps/api/cli.py b/apps/api/cli.py index 603dbf0b..b4238ac4 100644 --- a/apps/api/cli.py +++ b/apps/api/cli.py @@ -48,6 +48,7 @@ def install( slug="default", email="", logo_image="", + thumbnail_image="", ) install_create_organization(org, db_session) print("Default organization created ✅") @@ -89,6 +90,7 @@ def install( slug=slug.lower(), email="", logo_image="", + thumbnail_image="", ) install_create_organization(org, db_session) print(orgname + " Organization created ✅") diff --git a/apps/api/config/config.py b/apps/api/config/config.py index 4ff84e96..c9a7bd23 100644 --- a/apps/api/config/config.py +++ b/apps/api/config/config.py @@ -71,6 +71,18 @@ class RedisConfig(BaseModel): redis_connection_string: Optional[str] +class InternalStripeConfig(BaseModel): + stripe_secret_key: str | None + stripe_publishable_key: str | None + stripe_webhook_standard_secret: str | None + stripe_webhook_connect_secret: str | None + stripe_client_id: str | None + + +class InternalPaymentsConfig(BaseModel): + stripe: InternalStripeConfig + + class LearnHouseConfig(BaseModel): site_name: str site_description: str @@ -82,6 +94,7 @@ class LearnHouseConfig(BaseModel): security_config: SecurityConfig ai_config: AIConfig mailing_config: MailingConfig + payments_config: InternalPaymentsConfig def get_learnhouse_config() -> LearnHouseConfig: @@ -261,6 +274,33 @@ def get_learnhouse_config() -> LearnHouseConfig: else: sentry_config = None + # Payments config + env_stripe_secret_key = os.environ.get("LEARNHOUSE_STRIPE_SECRET_KEY") + env_stripe_publishable_key = os.environ.get("LEARNHOUSE_STRIPE_PUBLISHABLE_KEY") + env_stripe_webhook_standard_secret = os.environ.get("LEARNHOUSE_STRIPE_WEBHOOK_STANDARD_SECRET") + env_stripe_webhook_connect_secret = os.environ.get("LEARNHOUSE_STRIPE_WEBHOOK_CONNECT_SECRET") + env_stripe_client_id = os.environ.get("LEARNHOUSE_STRIPE_CLIENT_ID") + + stripe_secret_key = env_stripe_secret_key or yaml_config.get("payments_config", {}).get( + "stripe", {} + ).get("stripe_secret_key") + + stripe_publishable_key = env_stripe_publishable_key or yaml_config.get("payments_config", {}).get( + "stripe", {} + ).get("stripe_publishable_key") + + stripe_webhook_standard_secret = env_stripe_webhook_standard_secret or yaml_config.get("payments_config", {}).get( + "stripe", {} + ).get("stripe_webhook_standard_secret") + + stripe_webhook_connect_secret = env_stripe_webhook_connect_secret or yaml_config.get("payments_config", {}).get( + "stripe", {} + ).get("stripe_webhook_connect_secret") + + stripe_client_id = env_stripe_client_id or yaml_config.get("payments_config", {}).get( + "stripe", {} + ).get("stripe_client_id") + # Create HostingConfig and DatabaseConfig objects hosting_config = HostingConfig( domain=domain, @@ -303,6 +343,15 @@ def get_learnhouse_config() -> LearnHouseConfig: mailing_config=MailingConfig( resend_api_key=resend_api_key, system_email_address=system_email_address ), + payments_config=InternalPaymentsConfig( + stripe=InternalStripeConfig( + stripe_secret_key=stripe_secret_key, + stripe_publishable_key=stripe_publishable_key, + stripe_webhook_standard_secret=stripe_webhook_standard_secret, + stripe_webhook_connect_secret=stripe_webhook_connect_secret, + stripe_client_id=stripe_client_id + ) + ) ) return config diff --git a/apps/api/config/config.yaml b/apps/api/config/config.yaml index b9a8c0b6..c9dcec79 100644 --- a/apps/api/config/config.yaml +++ b/apps/api/config/config.yaml @@ -37,6 +37,13 @@ database_config: redis_config: redis_connection_string: redis://localhost:6379/learnhouse +payments_config: + stripe: + stripe_secret_key: "" + stripe_publishable_key: "" + stripe_webhook_standard_secret: "" + stripe_client_id: "" + ai_config: chromadb_config: isSeparateDatabaseEnabled: True diff --git a/apps/api/migrations/versions/0314ec7791e1_payments.py b/apps/api/migrations/versions/0314ec7791e1_payments.py new file mode 100644 index 00000000..91d71a87 --- /dev/null +++ b/apps/api/migrations/versions/0314ec7791e1_payments.py @@ -0,0 +1,90 @@ +"""Payments + +Revision ID: 0314ec7791e1 +Revises: 040ccb1d456e +Create Date: 2024-11-23 19:41:14.064680 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa # noqa: F401 +import sqlmodel # noqa: F401 +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '0314ec7791e1' +down_revision: Union[str, None] = '040ccb1d456e' +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('paymentsconfig', + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('active', sa.Boolean(), nullable=False), + sa.Column('provider', postgresql.ENUM('STRIPE', name='paymentproviderenum', create_type=False), nullable=False), + sa.Column('provider_specific_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('provider_config', sa.JSON(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('org_id', sa.BigInteger(), nullable=True), + sa.Column('creation_date', sa.DateTime(), nullable=False), + sa.Column('update_date', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['org_id'], ['organization.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('paymentsproduct', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('product_type', postgresql.ENUM('SUBSCRIPTION', 'ONE_TIME', name='paymentproducttypeenum', create_type=False), nullable=False), + sa.Column('price_type', postgresql.ENUM('CUSTOMER_CHOICE', 'FIXED_PRICE', name='paymentpricetypeenum', create_type=False), nullable=False), + sa.Column('benefits', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('currency', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('org_id', sa.BigInteger(), nullable=True), + sa.Column('payments_config_id', sa.BigInteger(), nullable=True), + sa.Column('provider_product_id', sa.String(), nullable=True), + sa.Column('creation_date', sa.DateTime(), nullable=False), + sa.Column('update_date', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['org_id'], ['organization.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['payments_config_id'], ['paymentsconfig.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('paymentscourse', + sa.Column('course_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('payment_product_id', sa.BigInteger(), nullable=True), + sa.Column('org_id', sa.BigInteger(), nullable=True), + sa.Column('creation_date', sa.DateTime(), nullable=False), + sa.Column('update_date', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['course_id'], ['course.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['org_id'], ['organization.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['payment_product_id'], ['paymentsproduct.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('paymentsuser', + sa.Column('status', postgresql.ENUM('PENDING', 'COMPLETED', 'ACTIVE', 'CANCELLED', 'FAILED', 'REFUNDED', name='paymentstatusenum', create_type=False), nullable=False), + sa.Column('provider_specific_data', sa.JSON(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.BigInteger(), nullable=True), + sa.Column('org_id', sa.BigInteger(), nullable=True), + sa.Column('payment_product_id', sa.BigInteger(), nullable=True), + sa.Column('creation_date', sa.DateTime(), nullable=False), + sa.Column('update_date', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['org_id'], ['organization.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['payment_product_id'], ['paymentsproduct.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('paymentsuser') + op.drop_table('paymentscourse') + op.drop_table('paymentsproduct') + op.drop_table('paymentsconfig') + # ### end Alembic commands ### diff --git a/apps/api/poetry.lock b/apps/api/poetry.lock index e5ad5e70..7a4f2827 100644 --- a/apps/api/poetry.lock +++ b/apps/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -276,17 +276,17 @@ typecheck = ["mypy"] [[package]] name = "boto3" -version = "1.35.49" +version = "1.35.52" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" files = [ - {file = "boto3-1.35.49-py3-none-any.whl", hash = "sha256:b660c649a27a6b47a34f6f858f5bd7c3b0a798a16dec8dda7cbebeee80fd1f60"}, - {file = "boto3-1.35.49.tar.gz", hash = "sha256:ddecb27f5699ca9f97711c52b6c0652c2e63bf6c2bfbc13b819b4f523b4d30ff"}, + {file = "boto3-1.35.52-py3-none-any.whl", hash = "sha256:ec0e797441db56af63b1150bba49f114b0f885f5d76c3b6dc18075f73030d2bb"}, + {file = "boto3-1.35.52.tar.gz", hash = "sha256:68299da8ab2bb37cc843d61b9f4c1c9367438406cfd65a8f593afc7b3bfe226d"}, ] [package.dependencies] -botocore = ">=1.35.49,<1.36.0" +botocore = ">=1.35.52,<1.36.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.10.0,<0.11.0" @@ -295,13 +295,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.35.49" +version = "1.35.52" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" files = [ - {file = "botocore-1.35.49-py3-none-any.whl", hash = "sha256:aed4d3643afd702920792b68fbe712a8c3847993820d1048cd238a6469354da1"}, - {file = "botocore-1.35.49.tar.gz", hash = "sha256:07d0c1325fdbfa49a4a054413dbdeab0a6030449b2aa66099241af2dac48afd8"}, + {file = "botocore-1.35.52-py3-none-any.whl", hash = "sha256:cdbb5e43c9c3a977763e2a10d3b8b9c405d51279f9fcfd4ca4800763b22acba5"}, + {file = "botocore-1.35.52.tar.gz", hash = "sha256:1fe7485ea13d638b089103addd818c12984ff1e4d208de15f180b1e25ad944c5"}, ] [package.dependencies] @@ -514,13 +514,13 @@ numpy = "*" [[package]] name = "chromadb" -version = "0.5.11" +version = "0.5.16" description = "Chroma." optional = false python-versions = ">=3.8" files = [ - {file = "chromadb-0.5.11-py3-none-any.whl", hash = "sha256:f02d9326869cea926f980bd6c9a0150a0ef2e151072f325998c16a9502fb4b25"}, - {file = "chromadb-0.5.11.tar.gz", hash = "sha256:252e970b3e1a27b594cc7b3685238691bf8eaa232225d4dee9e33ec83580775f"}, + {file = "chromadb-0.5.16-py3-none-any.whl", hash = "sha256:ae96f1c81fa691a163a2d625dc769c5c1afa3219d1ac26796fbf9d60d7924d71"}, + {file = "chromadb-0.5.16.tar.gz", hash = "sha256:ab947065125908b228cc343e7d9f21bcea5036dcd237d993caa66e5fc262dd9e"}, ] [package.dependencies] @@ -1053,70 +1053,70 @@ test = ["objgraph", "psutil"] [[package]] name = "grpcio" -version = "1.67.0" +version = "1.67.1" description = "HTTP/2-based RPC framework" optional = false python-versions = ">=3.8" files = [ - {file = "grpcio-1.67.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:bd79929b3bb96b54df1296cd3bf4d2b770bd1df6c2bdf549b49bab286b925cdc"}, - {file = "grpcio-1.67.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:16724ffc956ea42967f5758c2f043faef43cb7e48a51948ab593570570d1e68b"}, - {file = "grpcio-1.67.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:2b7183c80b602b0ad816315d66f2fb7887614ead950416d60913a9a71c12560d"}, - {file = "grpcio-1.67.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:efe32b45dd6d118f5ea2e5deaed417d8a14976325c93812dd831908522b402c9"}, - {file = "grpcio-1.67.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe89295219b9c9e47780a0f1c75ca44211e706d1c598242249fe717af3385ec8"}, - {file = "grpcio-1.67.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa8d025fae1595a207b4e47c2e087cb88d47008494db258ac561c00877d4c8f8"}, - {file = "grpcio-1.67.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f95e15db43e75a534420e04822df91f645664bf4ad21dfaad7d51773c80e6bb4"}, - {file = "grpcio-1.67.0-cp310-cp310-win32.whl", hash = "sha256:a6b9a5c18863fd4b6624a42e2712103fb0f57799a3b29651c0e5b8119a519d65"}, - {file = "grpcio-1.67.0-cp310-cp310-win_amd64.whl", hash = "sha256:b6eb68493a05d38b426604e1dc93bfc0137c4157f7ab4fac5771fd9a104bbaa6"}, - {file = "grpcio-1.67.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:e91d154689639932305b6ea6f45c6e46bb51ecc8ea77c10ef25aa77f75443ad4"}, - {file = "grpcio-1.67.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cb204a742997277da678611a809a8409657b1398aaeebf73b3d9563b7d154c13"}, - {file = "grpcio-1.67.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:ae6de510f670137e755eb2a74b04d1041e7210af2444103c8c95f193340d17ee"}, - {file = "grpcio-1.67.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74b900566bdf68241118f2918d312d3bf554b2ce0b12b90178091ea7d0a17b3d"}, - {file = "grpcio-1.67.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4e95e43447a02aa603abcc6b5e727d093d161a869c83b073f50b9390ecf0fa8"}, - {file = "grpcio-1.67.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0bb94e66cd8f0baf29bd3184b6aa09aeb1a660f9ec3d85da615c5003154bc2bf"}, - {file = "grpcio-1.67.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:82e5bd4b67b17c8c597273663794a6a46a45e44165b960517fe6d8a2f7f16d23"}, - {file = "grpcio-1.67.0-cp311-cp311-win32.whl", hash = "sha256:7fc1d2b9fd549264ae585026b266ac2db53735510a207381be509c315b4af4e8"}, - {file = "grpcio-1.67.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac11ecb34a86b831239cc38245403a8de25037b448464f95c3315819e7519772"}, - {file = "grpcio-1.67.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:227316b5631260e0bef8a3ce04fa7db4cc81756fea1258b007950b6efc90c05d"}, - {file = "grpcio-1.67.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d90cfdafcf4b45a7a076e3e2a58e7bc3d59c698c4f6470b0bb13a4d869cf2273"}, - {file = "grpcio-1.67.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:77196216d5dd6f99af1c51e235af2dd339159f657280e65ce7e12c1a8feffd1d"}, - {file = "grpcio-1.67.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15c05a26a0f7047f720da41dc49406b395c1470eef44ff7e2c506a47ac2c0591"}, - {file = "grpcio-1.67.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3840994689cc8cbb73d60485c594424ad8adb56c71a30d8948d6453083624b52"}, - {file = "grpcio-1.67.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:5a1e03c3102b6451028d5dc9f8591131d6ab3c8a0e023d94c28cb930ed4b5f81"}, - {file = "grpcio-1.67.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:682968427a63d898759474e3b3178d42546e878fdce034fd7474ef75143b64e3"}, - {file = "grpcio-1.67.0-cp312-cp312-win32.whl", hash = "sha256:d01793653248f49cf47e5695e0a79805b1d9d4eacef85b310118ba1dfcd1b955"}, - {file = "grpcio-1.67.0-cp312-cp312-win_amd64.whl", hash = "sha256:985b2686f786f3e20326c4367eebdaed3e7aa65848260ff0c6644f817042cb15"}, - {file = "grpcio-1.67.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:8c9a35b8bc50db35ab8e3e02a4f2a35cfba46c8705c3911c34ce343bd777813a"}, - {file = "grpcio-1.67.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:42199e704095b62688998c2d84c89e59a26a7d5d32eed86d43dc90e7a3bd04aa"}, - {file = "grpcio-1.67.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:c4c425f440fb81f8d0237c07b9322fc0fb6ee2b29fbef5f62a322ff8fcce240d"}, - {file = "grpcio-1.67.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:323741b6699cd2b04a71cb38f502db98f90532e8a40cb675393d248126a268af"}, - {file = "grpcio-1.67.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:662c8e105c5e5cee0317d500eb186ed7a93229586e431c1bf0c9236c2407352c"}, - {file = "grpcio-1.67.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f6bd2ab135c64a4d1e9e44679a616c9bc944547357c830fafea5c3caa3de5153"}, - {file = "grpcio-1.67.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:2f55c1e0e2ae9bdd23b3c63459ee4c06d223b68aeb1961d83c48fb63dc29bc03"}, - {file = "grpcio-1.67.0-cp313-cp313-win32.whl", hash = "sha256:fd6bc27861e460fe28e94226e3673d46e294ca4673d46b224428d197c5935e69"}, - {file = "grpcio-1.67.0-cp313-cp313-win_amd64.whl", hash = "sha256:cf51d28063338608cd8d3cd64677e922134837902b70ce00dad7f116e3998210"}, - {file = "grpcio-1.67.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:7f200aca719c1c5dc72ab68be3479b9dafccdf03df530d137632c534bb6f1ee3"}, - {file = "grpcio-1.67.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0892dd200ece4822d72dd0952f7112c542a487fc48fe77568deaaa399c1e717d"}, - {file = "grpcio-1.67.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:f4d613fbf868b2e2444f490d18af472ccb47660ea3df52f068c9c8801e1f3e85"}, - {file = "grpcio-1.67.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c69bf11894cad9da00047f46584d5758d6ebc9b5950c0dc96fec7e0bce5cde9"}, - {file = "grpcio-1.67.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9bca3ca0c5e74dea44bf57d27e15a3a3996ce7e5780d61b7c72386356d231db"}, - {file = "grpcio-1.67.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:014dfc020e28a0d9be7e93a91f85ff9f4a87158b7df9952fe23cc42d29d31e1e"}, - {file = "grpcio-1.67.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d4ea4509d42c6797539e9ec7496c15473177ce9abc89bc5c71e7abe50fc25737"}, - {file = "grpcio-1.67.0-cp38-cp38-win32.whl", hash = "sha256:9d75641a2fca9ae1ae86454fd25d4c298ea8cc195dbc962852234d54a07060ad"}, - {file = "grpcio-1.67.0-cp38-cp38-win_amd64.whl", hash = "sha256:cff8e54d6a463883cda2fab94d2062aad2f5edd7f06ae3ed030f2a74756db365"}, - {file = "grpcio-1.67.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:62492bd534979e6d7127b8a6b29093161a742dee3875873e01964049d5250a74"}, - {file = "grpcio-1.67.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eef1dce9d1a46119fd09f9a992cf6ab9d9178b696382439446ca5f399d7b96fe"}, - {file = "grpcio-1.67.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:f623c57a5321461c84498a99dddf9d13dac0e40ee056d884d6ec4ebcab647a78"}, - {file = "grpcio-1.67.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54d16383044e681f8beb50f905249e4e7261dd169d4aaf6e52eab67b01cbbbe2"}, - {file = "grpcio-1.67.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2a44e572fb762c668e4812156b81835f7aba8a721b027e2d4bb29fb50ff4d33"}, - {file = "grpcio-1.67.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:391df8b0faac84d42f5b8dfc65f5152c48ed914e13c522fd05f2aca211f8bfad"}, - {file = "grpcio-1.67.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfd9306511fdfc623a1ba1dc3bc07fbd24e6cfbe3c28b4d1e05177baa2f99617"}, - {file = "grpcio-1.67.0-cp39-cp39-win32.whl", hash = "sha256:30d47dbacfd20cbd0c8be9bfa52fdb833b395d4ec32fe5cff7220afc05d08571"}, - {file = "grpcio-1.67.0-cp39-cp39-win_amd64.whl", hash = "sha256:f55f077685f61f0fbd06ea355142b71e47e4a26d2d678b3ba27248abfe67163a"}, - {file = "grpcio-1.67.0.tar.gz", hash = "sha256:e090b2553e0da1c875449c8e75073dd4415dd71c9bde6a406240fdf4c0ee467c"}, + {file = "grpcio-1.67.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:8b0341d66a57f8a3119b77ab32207072be60c9bf79760fa609c5609f2deb1f3f"}, + {file = "grpcio-1.67.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:f5a27dddefe0e2357d3e617b9079b4bfdc91341a91565111a21ed6ebbc51b22d"}, + {file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:43112046864317498a33bdc4797ae6a268c36345a910de9b9c17159d8346602f"}, + {file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9b929f13677b10f63124c1a410994a401cdd85214ad83ab67cc077fc7e480f0"}, + {file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7d1797a8a3845437d327145959a2c0c47c05947c9eef5ff1a4c80e499dcc6fa"}, + {file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0489063974d1452436139501bf6b180f63d4977223ee87488fe36858c5725292"}, + {file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9fd042de4a82e3e7aca44008ee2fb5da01b3e5adb316348c21980f7f58adc311"}, + {file = "grpcio-1.67.1-cp310-cp310-win32.whl", hash = "sha256:638354e698fd0c6c76b04540a850bf1db27b4d2515a19fcd5cf645c48d3eb1ed"}, + {file = "grpcio-1.67.1-cp310-cp310-win_amd64.whl", hash = "sha256:608d87d1bdabf9e2868b12338cd38a79969eaf920c89d698ead08f48de9c0f9e"}, + {file = "grpcio-1.67.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:7818c0454027ae3384235a65210bbf5464bd715450e30a3d40385453a85a70cb"}, + {file = "grpcio-1.67.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ea33986b70f83844cd00814cee4451055cd8cab36f00ac64a31f5bb09b31919e"}, + {file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:c7a01337407dd89005527623a4a72c5c8e2894d22bead0895306b23c6695698f"}, + {file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b866f73224b0634f4312a4674c1be21b2b4afa73cb20953cbbb73a6b36c3cc"}, + {file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fff78ba10d4250bfc07a01bd6254a6d87dc67f9627adece85c0b2ed754fa96"}, + {file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a23cbcc5bb11ea7dc6163078be36c065db68d915c24f5faa4f872c573bb400f"}, + {file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1a65b503d008f066e994f34f456e0647e5ceb34cfcec5ad180b1b44020ad4970"}, + {file = "grpcio-1.67.1-cp311-cp311-win32.whl", hash = "sha256:e29ca27bec8e163dca0c98084040edec3bc49afd10f18b412f483cc68c712744"}, + {file = "grpcio-1.67.1-cp311-cp311-win_amd64.whl", hash = "sha256:786a5b18544622bfb1e25cc08402bd44ea83edfb04b93798d85dca4d1a0b5be5"}, + {file = "grpcio-1.67.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:267d1745894200e4c604958da5f856da6293f063327cb049a51fe67348e4f953"}, + {file = "grpcio-1.67.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85f69fdc1d28ce7cff8de3f9c67db2b0ca9ba4449644488c1e0303c146135ddb"}, + {file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f26b0b547eb8d00e195274cdfc63ce64c8fc2d3e2d00b12bf468ece41a0423a0"}, + {file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4422581cdc628f77302270ff839a44f4c24fdc57887dc2a45b7e53d8fc2376af"}, + {file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d7616d2ded471231c701489190379e0c311ee0a6c756f3c03e6a62b95a7146e"}, + {file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8a00efecde9d6fcc3ab00c13f816313c040a28450e5e25739c24f432fc6d3c75"}, + {file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:699e964923b70f3101393710793289e42845791ea07565654ada0969522d0a38"}, + {file = "grpcio-1.67.1-cp312-cp312-win32.whl", hash = "sha256:4e7b904484a634a0fff132958dabdb10d63e0927398273917da3ee103e8d1f78"}, + {file = "grpcio-1.67.1-cp312-cp312-win_amd64.whl", hash = "sha256:5721e66a594a6c4204458004852719b38f3d5522082be9061d6510b455c90afc"}, + {file = "grpcio-1.67.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa0162e56fd10a5547fac8774c4899fc3e18c1aa4a4759d0ce2cd00d3696ea6b"}, + {file = "grpcio-1.67.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:beee96c8c0b1a75d556fe57b92b58b4347c77a65781ee2ac749d550f2a365dc1"}, + {file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:a93deda571a1bf94ec1f6fcda2872dad3ae538700d94dc283c672a3b508ba3af"}, + {file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e6f255980afef598a9e64a24efce87b625e3e3c80a45162d111a461a9f92955"}, + {file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e838cad2176ebd5d4a8bb03955138d6589ce9e2ce5d51c3ada34396dbd2dba8"}, + {file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a6703916c43b1d468d0756c8077b12017a9fcb6a1ef13faf49e67d20d7ebda62"}, + {file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:917e8d8994eed1d86b907ba2a61b9f0aef27a2155bca6cbb322430fc7135b7bb"}, + {file = "grpcio-1.67.1-cp313-cp313-win32.whl", hash = "sha256:e279330bef1744040db8fc432becc8a727b84f456ab62b744d3fdb83f327e121"}, + {file = "grpcio-1.67.1-cp313-cp313-win_amd64.whl", hash = "sha256:fa0c739ad8b1996bd24823950e3cb5152ae91fca1c09cc791190bf1627ffefba"}, + {file = "grpcio-1.67.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:178f5db771c4f9a9facb2ab37a434c46cb9be1a75e820f187ee3d1e7805c4f65"}, + {file = "grpcio-1.67.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f3e49c738396e93b7ba9016e153eb09e0778e776df6090c1b8c91877cc1c426"}, + {file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:24e8a26dbfc5274d7474c27759b54486b8de23c709d76695237515bc8b5baeab"}, + {file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b6c16489326d79ead41689c4b84bc40d522c9a7617219f4ad94bc7f448c5085"}, + {file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60e6a4dcf5af7bbc36fd9f81c9f372e8ae580870a9e4b6eafe948cd334b81cf3"}, + {file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:95b5f2b857856ed78d72da93cd7d09b6db8ef30102e5e7fe0961fe4d9f7d48e8"}, + {file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b49359977c6ec9f5d0573ea4e0071ad278ef905aa74e420acc73fd28ce39e9ce"}, + {file = "grpcio-1.67.1-cp38-cp38-win32.whl", hash = "sha256:f5b76ff64aaac53fede0cc93abf57894ab2a7362986ba22243d06218b93efe46"}, + {file = "grpcio-1.67.1-cp38-cp38-win_amd64.whl", hash = "sha256:804c6457c3cd3ec04fe6006c739579b8d35c86ae3298ffca8de57b493524b771"}, + {file = "grpcio-1.67.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:a25bdea92b13ff4d7790962190bf6bf5c4639876e01c0f3dda70fc2769616335"}, + {file = "grpcio-1.67.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc491ae35a13535fd9196acb5afe1af37c8237df2e54427be3eecda3653127e"}, + {file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:85f862069b86a305497e74d0dc43c02de3d1d184fc2c180993aa8aa86fbd19b8"}, + {file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec74ef02010186185de82cc594058a3ccd8d86821842bbac9873fd4a2cf8be8d"}, + {file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01f616a964e540638af5130469451cf580ba8c7329f45ca998ab66e0c7dcdb04"}, + {file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:299b3d8c4f790c6bcca485f9963b4846dd92cf6f1b65d3697145d005c80f9fe8"}, + {file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:60336bff760fbb47d7e86165408126f1dded184448e9a4c892189eb7c9d3f90f"}, + {file = "grpcio-1.67.1-cp39-cp39-win32.whl", hash = "sha256:5ed601c4c6008429e3d247ddb367fe8c7259c355757448d7c1ef7bd4a6739e8e"}, + {file = "grpcio-1.67.1-cp39-cp39-win_amd64.whl", hash = "sha256:5db70d32d6703b89912af16d6d45d78406374a8b8ef0d28140351dd0ec610e98"}, + {file = "grpcio-1.67.1.tar.gz", hash = "sha256:3dc2ed4cabea4dc14d5e708c2b426205956077cc5de419b4d4079315017e9732"}, ] [package.extras] -protobuf = ["grpcio-tools (>=1.67.0)"] +protobuf = ["grpcio-tools (>=1.67.1)"] [[package]] name = "h11" @@ -1232,13 +1232,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "huggingface-hub" -version = "0.26.1" +version = "0.26.2" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.8.0" files = [ - {file = "huggingface_hub-0.26.1-py3-none-any.whl", hash = "sha256:5927a8fc64ae68859cd954b7cc29d1c8390a5e15caba6d3d349c973be8fdacf3"}, - {file = "huggingface_hub-0.26.1.tar.gz", hash = "sha256:414c0d9b769eecc86c70f9d939d0f48bb28e8461dd1130021542eff0212db890"}, + {file = "huggingface_hub-0.26.2-py3-none-any.whl", hash = "sha256:98c2a5a8e786c7b2cb6fdeb2740893cba4d53e312572ed3d8afafda65b128c46"}, + {file = "huggingface_hub-0.26.2.tar.gz", hash = "sha256:b100d853465d965733964d123939ba287da60a547087783ddff8a323f340332b"}, ] [package.dependencies] @@ -1599,13 +1599,13 @@ tenacity = ">=8.1.0,<9.0.0" [[package]] name = "langchain-core" -version = "0.2.41" +version = "0.2.42" description = "Building applications with LLMs through composability" optional = false python-versions = "<4.0,>=3.8.1" files = [ - {file = "langchain_core-0.2.41-py3-none-any.whl", hash = "sha256:3278fda5ba9a05defae8bb19f1226032add6aab21917db7b3bc74e750e263e84"}, - {file = "langchain_core-0.2.41.tar.gz", hash = "sha256:bc12032c5a298d85be754ccb129bc13ea21ccb1d6e22f8d7ba18b8da64315bb5"}, + {file = "langchain_core-0.2.42-py3-none-any.whl", hash = "sha256:09503fdfb9efa163e51f2d9762894fde04797d0a41462c0e6072ef78028e48fd"}, + {file = "langchain_core-0.2.42.tar.gz", hash = "sha256:e4ea04b22bd6398048d0ef97cd3132fbdd80e6c749863ee96e6b7c88502ff913"}, ] [package.dependencies] @@ -1698,13 +1698,13 @@ requests = ">=2,<3" [[package]] name = "langsmith" -version = "0.1.137" +version = "0.1.138" description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform." optional = false python-versions = "<4.0,>=3.8.1" files = [ - {file = "langsmith-0.1.137-py3-none-any.whl", hash = "sha256:4256d5c61133749890f7b5c88321dbb133ce0f440c621ea28e76513285859b81"}, - {file = "langsmith-0.1.137.tar.gz", hash = "sha256:56cdfcc6c74cb20a3f437d5bd144feb5bf93f54c5a2918d1e568cbd084a372d4"}, + {file = "langsmith-0.1.138-py3-none-any.whl", hash = "sha256:5c2bd5c11c75f7b3d06a0f06b115186e7326ca969fd26d66ffc65a0669012aee"}, + {file = "langsmith-0.1.138.tar.gz", hash = "sha256:1ecf613bb52f6bf17f1510e24ad8b70d4b0259bc9d3dbfd69b648c66d4644f0b"}, ] [package.dependencies] @@ -2215,13 +2215,13 @@ sympy = "*" [[package]] name = "openai" -version = "1.52.2" +version = "1.53.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.7.1" files = [ - {file = "openai-1.52.2-py3-none-any.whl", hash = "sha256:57e9e37bc407f39bb6ec3a27d7e8fb9728b2779936daa1fcf95df17d3edfaccc"}, - {file = "openai-1.52.2.tar.gz", hash = "sha256:87b7d0f69d85f5641678d414b7ee3082363647a5c66a462ed7f3ccb59582da0d"}, + {file = "openai-1.53.0-py3-none-any.whl", hash = "sha256:20f408c32fc5cb66e60c6882c994cdca580a5648e10045cd840734194f033418"}, + {file = "openai-1.53.0.tar.gz", hash = "sha256:be2c4e77721b166cce8130e544178b7d579f751b4b074ffbaade3854b6f85ec5"}, ] [package.dependencies] @@ -3332,23 +3332,23 @@ tornado = ["tornado (>=6)"] [[package]] name = "setuptools" -version = "75.2.0" +version = "75.3.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8"}, - {file = "setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec"}, + {file = "setuptools-75.3.0-py3-none-any.whl", hash = "sha256:f2504966861356aa38616760c0f66568e535562374995367b4e69c7143cf6bcd"}, + {file = "setuptools-75.3.0.tar.gz", hash = "sha256:fba5dd4d766e97be1b1681d98712680ae8f2f26d7881245f2ce9e40714f1a686"}, ] [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12.*)", "pytest-mypy"] [[package]] name = "shellingham" @@ -3538,6 +3538,21 @@ anyio = ">=3.4.0,<5" [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] +[[package]] +name = "stripe" +version = "11.2.0" +description = "Python bindings for the Stripe API" +optional = false +python-versions = ">=3.6" +files = [ + {file = "stripe-11.2.0-py2.py3-none-any.whl", hash = "sha256:dec812eabc95488862be40e6c799acdaf2e1225d686490a793f949fab745fdd0"}, + {file = "stripe-11.2.0.tar.gz", hash = "sha256:4c53d61d7b596070324bfa5d7215843145fe5466e48973d828aab41ad209b5ce"}, +] + +[package.dependencies] +requests = {version = ">=2.20", markers = "python_version >= \"3.0\""} +typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""} + [[package]] name = "sympy" version = "1.13.3" @@ -3741,13 +3756,13 @@ testing = ["black (==22.3)", "datasets", "numpy", "pytest", "requests", "ruff"] [[package]] name = "tqdm" -version = "4.66.5" +version = "4.66.6" description = "Fast, Extensible Progress Meter" optional = false python-versions = ">=3.7" files = [ - {file = "tqdm-4.66.5-py3-none-any.whl", hash = "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd"}, - {file = "tqdm-4.66.5.tar.gz", hash = "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad"}, + {file = "tqdm-4.66.6-py3-none-any.whl", hash = "sha256:223e8b5359c2efc4b30555531f09e9f2f3589bcd7fdd389271191031b49b7a63"}, + {file = "tqdm-4.66.6.tar.gz", hash = "sha256:4bdd694238bef1485ce839d67967ab50af8f9272aab687c0d7702a01da0be090"}, ] [package.dependencies] @@ -4182,93 +4197,93 @@ files = [ [[package]] name = "yarl" -version = "1.16.0" +version = "1.17.1" description = "Yet another URL library" optional = false python-versions = ">=3.9" files = [ - {file = "yarl-1.16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32468f41242d72b87ab793a86d92f885355bcf35b3355aa650bfa846a5c60058"}, - {file = "yarl-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:234f3a3032b505b90e65b5bc6652c2329ea7ea8855d8de61e1642b74b4ee65d2"}, - {file = "yarl-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a0296040e5cddf074c7f5af4a60f3fc42c0237440df7bcf5183be5f6c802ed5"}, - {file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de6c14dd7c7c0badba48157474ea1f03ebee991530ba742d381b28d4f314d6f3"}, - {file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b140e532fe0266003c936d017c1ac301e72ee4a3fd51784574c05f53718a55d8"}, - {file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:019f5d58093402aa8f6661e60fd82a28746ad6d156f6c5336a70a39bd7b162b9"}, - {file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c42998fd1cbeb53cd985bff0e4bc25fbe55fd6eb3a545a724c1012d69d5ec84"}, - {file = "yarl-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c7c30fb38c300fe8140df30a046a01769105e4cf4282567a29b5cdb635b66c4"}, - {file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e49e0fd86c295e743fd5be69b8b0712f70a686bc79a16e5268386c2defacaade"}, - {file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b9ca7b9147eb1365c8bab03c003baa1300599575effad765e0b07dd3501ea9af"}, - {file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:27e11db3f1e6a51081a981509f75617b09810529de508a181319193d320bc5c7"}, - {file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8994c42f4ca25df5380ddf59f315c518c81df6a68fed5bb0c159c6cb6b92f120"}, - {file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:542fa8e09a581bcdcbb30607c7224beff3fdfb598c798ccd28a8184ffc18b7eb"}, - {file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2bd6a51010c7284d191b79d3b56e51a87d8e1c03b0902362945f15c3d50ed46b"}, - {file = "yarl-1.16.0-cp310-cp310-win32.whl", hash = "sha256:178ccb856e265174a79f59721031060f885aca428983e75c06f78aa24b91d929"}, - {file = "yarl-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe8bba2545427418efc1929c5c42852bdb4143eb8d0a46b09de88d1fe99258e7"}, - {file = "yarl-1.16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d8643975a0080f361639787415a038bfc32d29208a4bf6b783ab3075a20b1ef3"}, - {file = "yarl-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:676d96bafc8c2d0039cea0cd3fd44cee7aa88b8185551a2bb93354668e8315c2"}, - {file = "yarl-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9525f03269e64310416dbe6c68d3b23e5d34aaa8f47193a1c45ac568cecbc49"}, - {file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b37d5ec034e668b22cf0ce1074d6c21fd2a08b90d11b1b73139b750a8b0dd97"}, - {file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f32c4cb7386b41936894685f6e093c8dfaf0960124d91fe0ec29fe439e201d0"}, - {file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b8e265a0545637492a7e12fd7038370d66c9375a61d88c5567d0e044ded9202"}, - {file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:789a3423f28a5fff46fbd04e339863c169ece97c827b44de16e1a7a42bc915d2"}, - {file = "yarl-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1d1f45e3e8d37c804dca99ab3cf4ab3ed2e7a62cd82542924b14c0a4f46d243"}, - {file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:621280719c4c5dad4c1391160a9b88925bb8b0ff6a7d5af3224643024871675f"}, - {file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ed097b26f18a1f5ff05f661dc36528c5f6735ba4ce8c9645e83b064665131349"}, - {file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2f1fe2b2e3ee418862f5ebc0c0083c97f6f6625781382f828f6d4e9b614eba9b"}, - {file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:87dd10bc0618991c66cee0cc65fa74a45f4ecb13bceec3c62d78ad2e42b27a16"}, - {file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4199db024b58a8abb2cfcedac7b1292c3ad421684571aeb622a02f242280e8d6"}, - {file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:99a9dcd4b71dd5f5f949737ab3f356cfc058c709b4f49833aeffedc2652dac56"}, - {file = "yarl-1.16.0-cp311-cp311-win32.whl", hash = "sha256:a9394c65ae0ed95679717d391c862dece9afacd8fa311683fc8b4362ce8a410c"}, - {file = "yarl-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5b9101f528ae0f8f65ac9d64dda2bb0627de8a50344b2f582779f32fda747c1d"}, - {file = "yarl-1.16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4ffb7c129707dd76ced0a4a4128ff452cecf0b0e929f2668ea05a371d9e5c104"}, - {file = "yarl-1.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1a5e9d8ce1185723419c487758d81ac2bde693711947032cce600ca7c9cda7d6"}, - {file = "yarl-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d743e3118b2640cef7768ea955378c3536482d95550222f908f392167fe62059"}, - {file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26768342f256e6e3c37533bf9433f5f15f3e59e3c14b2409098291b3efaceacb"}, - {file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1b0796168b953bca6600c5f97f5ed407479889a36ad7d17183366260f29a6b9"}, - {file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:858728086914f3a407aa7979cab743bbda1fe2bdf39ffcd991469a370dd7414d"}, - {file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5570e6d47bcb03215baf4c9ad7bf7c013e56285d9d35013541f9ac2b372593e7"}, - {file = "yarl-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66ea8311422a7ba1fc79b4c42c2baa10566469fe5a78500d4e7754d6e6db8724"}, - {file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:649bddcedee692ee8a9b7b6e38582cb4062dc4253de9711568e5620d8707c2a3"}, - {file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3a91654adb7643cb21b46f04244c5a315a440dcad63213033826549fa2435f71"}, - {file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b439cae82034ade094526a8f692b9a2b5ee936452de5e4c5f0f6c48df23f8604"}, - {file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:571f781ae8ac463ce30bacebfaef2c6581543776d5970b2372fbe31d7bf31a07"}, - {file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:aa7943f04f36d6cafc0cf53ea89824ac2c37acbdb4b316a654176ab8ffd0f968"}, - {file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1a5cf32539373ff39d97723e39a9283a7277cbf1224f7aef0c56c9598b6486c3"}, - {file = "yarl-1.16.0-cp312-cp312-win32.whl", hash = "sha256:a5b6c09b9b4253d6a208b0f4a2f9206e511ec68dce9198e0fbec4f160137aa67"}, - {file = "yarl-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1208ca14eed2fda324042adf8d6c0adf4a31522fa95e0929027cd487875f0240"}, - {file = "yarl-1.16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5ace0177520bd4caa99295a9b6fb831d0e9a57d8e0501a22ffaa61b4c024283"}, - {file = "yarl-1.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7118bdb5e3ed81acaa2095cba7ec02a0fe74b52a16ab9f9ac8e28e53ee299732"}, - {file = "yarl-1.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38fec8a2a94c58bd47c9a50a45d321ab2285ad133adefbbadf3012c054b7e656"}, - {file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8791d66d81ee45866a7bb15a517b01a2bcf583a18ebf5d72a84e6064c417e64b"}, - {file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cf936ba67bc6c734f3aa1c01391da74ab7fc046a9f8bbfa230b8393b90cf472"}, - {file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1aab176dd55b59f77a63b27cffaca67d29987d91a5b615cbead41331e6b7428"}, - {file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:995d0759004c08abd5d1b81300a91d18c8577c6389300bed1c7c11675105a44d"}, - {file = "yarl-1.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bc22e00edeb068f71967ab99081e9406cd56dbed864fc3a8259442999d71552"}, - {file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35b4f7842154176523e0a63c9b871168c69b98065d05a4f637fce342a6a2693a"}, - {file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7ace71c4b7a0c41f317ae24be62bb61e9d80838d38acb20e70697c625e71f120"}, - {file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8f639e3f5795a6568aa4f7d2ac6057c757dcd187593679f035adbf12b892bb00"}, - {file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e8be3aff14f0120ad049121322b107f8a759be76a6a62138322d4c8a337a9e2c"}, - {file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:122d8e7986043d0549e9eb23c7fd23be078be4b70c9eb42a20052b3d3149c6f2"}, - {file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0fd9c227990f609c165f56b46107d0bc34553fe0387818c42c02f77974402c36"}, - {file = "yarl-1.16.0-cp313-cp313-win32.whl", hash = "sha256:595ca5e943baed31d56b33b34736461a371c6ea0038d3baec399949dd628560b"}, - {file = "yarl-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:921b81b8d78f0e60242fb3db615ea3f368827a76af095d5a69f1c3366db3f596"}, - {file = "yarl-1.16.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab2b2ac232110a1fdb0d3ffcd087783edd3d4a6ced432a1bf75caf7b7be70916"}, - {file = "yarl-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7f8713717a09acbfee7c47bfc5777e685539fefdd34fa72faf504c8be2f3df4e"}, - {file = "yarl-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdcffe1dbcb4477d2b4202f63cd972d5baa155ff5a3d9e35801c46a415b7f71a"}, - {file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a91217208306d82357c67daeef5162a41a28c8352dab7e16daa82e3718852a7"}, - {file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ab3ed42c78275477ea8e917491365e9a9b69bb615cb46169020bd0aa5e2d6d3"}, - {file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:707ae579ccb3262dfaef093e202b4c3fb23c3810e8df544b1111bd2401fd7b09"}, - {file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7a852d1cd0b8d8b37fc9d7f8581152add917a98cfe2ea6e241878795f917ae"}, - {file = "yarl-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3f1cc3d3d4dc574bebc9b387f6875e228ace5748a7c24f49d8f01ac1bc6c31b"}, - {file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5ff96da263740779b0893d02b718293cc03400c3a208fc8d8cd79d9b0993e532"}, - {file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:3d375a19ba2bfe320b6d873f3fb165313b002cef8b7cc0a368ad8b8a57453837"}, - {file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:62c7da0ad93a07da048b500514ca47b759459ec41924143e2ddb5d7e20fd3db5"}, - {file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:147b0fcd0ee33b4b5f6edfea80452d80e419e51b9a3f7a96ce98eaee145c1581"}, - {file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:504e1fe1cc4f170195320eb033d2b0ccf5c6114ce5bf2f617535c01699479bca"}, - {file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bdcf667a5dec12a48f669e485d70c54189f0639c2157b538a4cffd24a853624f"}, - {file = "yarl-1.16.0-cp39-cp39-win32.whl", hash = "sha256:e9951afe6557c75a71045148890052cb942689ee4c9ec29f5436240e1fcc73b7"}, - {file = "yarl-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:7d7aaa8ff95d0840e289423e7dc35696c2b058d635f945bf05b5cd633146b027"}, - {file = "yarl-1.16.0-py3-none-any.whl", hash = "sha256:e6980a558d8461230c457218bd6c92dfc1d10205548215c2c21d79dc8d0a96f3"}, - {file = "yarl-1.16.0.tar.gz", hash = "sha256:b6f687ced5510a9a2474bbae96a4352e5ace5fa34dc44a217b0537fec1db00b4"}, + {file = "yarl-1.17.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1794853124e2f663f0ea54efb0340b457f08d40a1cef78edfa086576179c91"}, + {file = "yarl-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fbea1751729afe607d84acfd01efd95e3b31db148a181a441984ce9b3d3469da"}, + {file = "yarl-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ee427208c675f1b6e344a1f89376a9613fc30b52646a04ac0c1f6587c7e46ec"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b74ff4767d3ef47ffe0cd1d89379dc4d828d4873e5528976ced3b44fe5b0a21"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62a91aefff3d11bf60e5956d340eb507a983a7ec802b19072bb989ce120cd948"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:846dd2e1243407133d3195d2d7e4ceefcaa5f5bf7278f0a9bda00967e6326b04"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e844be8d536afa129366d9af76ed7cb8dfefec99f5f1c9e4f8ae542279a6dc3"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc7c92c1baa629cb03ecb0c3d12564f172218fb1739f54bf5f3881844daadc6d"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ae3476e934b9d714aa8000d2e4c01eb2590eee10b9d8cd03e7983ad65dfbfcba"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c7e177c619342e407415d4f35dec63d2d134d951e24b5166afcdfd1362828e17"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64cc6e97f14cf8a275d79c5002281f3040c12e2e4220623b5759ea7f9868d6a5"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:84c063af19ef5130084db70ada40ce63a84f6c1ef4d3dbc34e5e8c4febb20822"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:482c122b72e3c5ec98f11457aeb436ae4aecca75de19b3d1de7cf88bc40db82f"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:380e6c38ef692b8fd5a0f6d1fa8774d81ebc08cfbd624b1bca62a4d4af2f9931"}, + {file = "yarl-1.17.1-cp310-cp310-win32.whl", hash = "sha256:16bca6678a83657dd48df84b51bd56a6c6bd401853aef6d09dc2506a78484c7b"}, + {file = "yarl-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:561c87fea99545ef7d692403c110b2f99dced6dff93056d6e04384ad3bc46243"}, + {file = "yarl-1.17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cbad927ea8ed814622305d842c93412cb47bd39a496ed0f96bfd42b922b4a217"}, + {file = "yarl-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fca4b4307ebe9c3ec77a084da3a9d1999d164693d16492ca2b64594340999988"}, + {file = "yarl-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff5c6771c7e3511a06555afa317879b7db8d640137ba55d6ab0d0c50425cab75"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b29beab10211a746f9846baa39275e80034e065460d99eb51e45c9a9495bcca"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a52a1ffdd824fb1835272e125385c32fd8b17fbdefeedcb4d543cc23b332d74"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58c8e9620eb82a189c6c40cb6b59b4e35b2ee68b1f2afa6597732a2b467d7e8f"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d216e5d9b8749563c7f2c6f7a0831057ec844c68b4c11cb10fc62d4fd373c26d"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:881764d610e3269964fc4bb3c19bb6fce55422828e152b885609ec176b41cf11"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8c79e9d7e3d8a32d4824250a9c6401194fb4c2ad9a0cec8f6a96e09a582c2cc0"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:299f11b44d8d3a588234adbe01112126010bd96d9139c3ba7b3badd9829261c3"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cc7d768260f4ba4ea01741c1b5fe3d3a6c70eb91c87f4c8761bbcce5181beafe"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:de599af166970d6a61accde358ec9ded821234cbbc8c6413acfec06056b8e860"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2b24ec55fad43e476905eceaf14f41f6478780b870eda5d08b4d6de9a60b65b4"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9fb815155aac6bfa8d86184079652c9715c812d506b22cfa369196ef4e99d1b4"}, + {file = "yarl-1.17.1-cp311-cp311-win32.whl", hash = "sha256:7615058aabad54416ddac99ade09a5510cf77039a3b903e94e8922f25ed203d7"}, + {file = "yarl-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:14bc88baa44e1f84164a392827b5defb4fa8e56b93fecac3d15315e7c8e5d8b3"}, + {file = "yarl-1.17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:327828786da2006085a4d1feb2594de6f6d26f8af48b81eb1ae950c788d97f61"}, + {file = "yarl-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cc353841428d56b683a123a813e6a686e07026d6b1c5757970a877195f880c2d"}, + {file = "yarl-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73df5b6e8fabe2ddb74876fb82d9dd44cbace0ca12e8861ce9155ad3c886139"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bdff5e0995522706c53078f531fb586f56de9c4c81c243865dd5c66c132c3b5"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06157fb3c58f2736a5e47c8fcbe1afc8b5de6fb28b14d25574af9e62150fcaac"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1654ec814b18be1af2c857aa9000de7a601400bd4c9ca24629b18486c2e35463"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f6595c852ca544aaeeb32d357e62c9c780eac69dcd34e40cae7b55bc4fb1147"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:459e81c2fb920b5f5df744262d1498ec2c8081acdcfe18181da44c50f51312f7"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e48cdb8226644e2fbd0bdb0a0f87906a3db07087f4de77a1b1b1ccfd9e93685"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d9b6b28a57feb51605d6ae5e61a9044a31742db557a3b851a74c13bc61de5172"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e594b22688d5747b06e957f1ef822060cb5cb35b493066e33ceac0cf882188b7"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5f236cb5999ccd23a0ab1bd219cfe0ee3e1c1b65aaf6dd3320e972f7ec3a39da"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a2a64e62c7a0edd07c1c917b0586655f3362d2c2d37d474db1a509efb96fea1c"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d0eea830b591dbc68e030c86a9569826145df485b2b4554874b07fea1275a199"}, + {file = "yarl-1.17.1-cp312-cp312-win32.whl", hash = "sha256:46ddf6e0b975cd680eb83318aa1d321cb2bf8d288d50f1754526230fcf59ba96"}, + {file = "yarl-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:117ed8b3732528a1e41af3aa6d4e08483c2f0f2e3d3d7dca7cf538b3516d93df"}, + {file = "yarl-1.17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5d1d42556b063d579cae59e37a38c61f4402b47d70c29f0ef15cee1acaa64488"}, + {file = "yarl-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0167540094838ee9093ef6cc2c69d0074bbf84a432b4995835e8e5a0d984374"}, + {file = "yarl-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f0a6423295a0d282d00e8701fe763eeefba8037e984ad5de44aa349002562ac"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5b078134f48552c4d9527db2f7da0b5359abd49393cdf9794017baec7506170"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d401f07261dc5aa36c2e4efc308548f6ae943bfff20fcadb0a07517a26b196d8"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5f1ac7359e17efe0b6e5fec21de34145caef22b260e978336f325d5c84e6938"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f63d176a81555984e91f2c84c2a574a61cab7111cc907e176f0f01538e9ff6e"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e275792097c9f7e80741c36de3b61917aebecc08a67ae62899b074566ff8556"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81713b70bea5c1386dc2f32a8f0dab4148a2928c7495c808c541ee0aae614d67"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:aa46dce75078fceaf7cecac5817422febb4355fbdda440db55206e3bd288cfb8"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1ce36ded585f45b1e9bb36d0ae94765c6608b43bd2e7f5f88079f7a85c61a4d3"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2d374d70fdc36f5863b84e54775452f68639bc862918602d028f89310a034ab0"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2d9f0606baaec5dd54cb99667fcf85183a7477f3766fbddbe3f385e7fc253299"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0341e6d9a0c0e3cdc65857ef518bb05b410dbd70d749a0d33ac0f39e81a4258"}, + {file = "yarl-1.17.1-cp313-cp313-win32.whl", hash = "sha256:2e7ba4c9377e48fb7b20dedbd473cbcbc13e72e1826917c185157a137dac9df2"}, + {file = "yarl-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:949681f68e0e3c25377462be4b658500e85ca24323d9619fdc41f68d46a1ffda"}, + {file = "yarl-1.17.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8994b29c462de9a8fce2d591028b986dbbe1b32f3ad600b2d3e1c482c93abad6"}, + {file = "yarl-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f9cbfbc5faca235fbdf531b93aa0f9f005ec7d267d9d738761a4d42b744ea159"}, + {file = "yarl-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b40d1bf6e6f74f7c0a567a9e5e778bbd4699d1d3d2c0fe46f4b717eef9e96b95"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5efe0661b9fcd6246f27957f6ae1c0eb29bc60552820f01e970b4996e016004"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5c4804e4039f487e942c13381e6c27b4b4e66066d94ef1fae3f6ba8b953f383"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5d6a6c9602fd4598fa07e0389e19fe199ae96449008d8304bf5d47cb745462e"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4c9156c4d1eb490fe374fb294deeb7bc7eaccda50e23775b2354b6a6739934"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6324274b4e0e2fa1b3eccb25997b1c9ed134ff61d296448ab8269f5ac068c4c"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d8a8b74d843c2638f3864a17d97a4acda58e40d3e44b6303b8cc3d3c44ae2d29"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:7fac95714b09da9278a0b52e492466f773cfe37651cf467a83a1b659be24bf71"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c180ac742a083e109c1a18151f4dd8675f32679985a1c750d2ff806796165b55"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:578d00c9b7fccfa1745a44f4eddfdc99d723d157dad26764538fbdda37209857"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1a3b91c44efa29e6c8ef8a9a2b583347998e2ba52c5d8280dbd5919c02dfc3b5"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7ac5b4984c468ce4f4a553df281450df0a34aefae02e58d77a0847be8d1e11f"}, + {file = "yarl-1.17.1-cp39-cp39-win32.whl", hash = "sha256:7294e38f9aa2e9f05f765b28ffdc5d81378508ce6dadbe93f6d464a8c9594473"}, + {file = "yarl-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:eb6dce402734575e1a8cc0bb1509afca508a400a57ce13d306ea2c663bad1138"}, + {file = "yarl-1.17.1-py3-none-any.whl", hash = "sha256:f1790a4b1e8e8e028c391175433b9c8122c39b46e1663228158e61e6f915bf06"}, + {file = "yarl-1.17.1.tar.gz", hash = "sha256:067a63fcfda82da6b198fa73079b1ca40b7c9b7994995b6ee38acda728b64d47"}, ] [package.dependencies] @@ -4298,4 +4313,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "8d540e367903004280ea01872d13776353e3a2dc144e6568267da01f35026bf0" +content-hash = "a654acc8816c96d1ec25bce16fe1afdd8d37e5ac38a0fa59ee7871dde5ca5229" diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index e12a1762..1216c45a 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -37,10 +37,11 @@ sqlmodel = "^0.0.19" tiktoken = "^0.7.0" uvicorn = "0.30.1" typer = "^0.12.5" -chromadb = "0.5.11" +chromadb = "0.5.16" alembic = "^1.13.2" alembic-postgresql-enum = "^1.2.0" sqlalchemy-utils = "^0.41.2" +stripe = "^11.1.1" [build-system] build-backend = "poetry.core.masonry.api" diff --git a/apps/api/src/core/events/database.py b/apps/api/src/core/events/database.py index 01318298..e910f628 100644 --- a/apps/api/src/core/events/database.py +++ b/apps/api/src/core/events/database.py @@ -1,26 +1,54 @@ import logging +import os +import importlib from config.config import get_learnhouse_config from fastapi import FastAPI from sqlmodel import SQLModel, Session, create_engine +def import_all_models(): + base_dir = 'src/db' + base_module_path = 'src.db' + + # Recursively walk through the base directory + for root, dirs, files in os.walk(base_dir): + # Filter out __init__.py and non-Python files + module_files = [f for f in files if f.endswith('.py') and f != '__init__.py'] + + # Calculate the module's base path from its directory structure + path_diff = os.path.relpath(root, base_dir) + if path_diff == '.': + current_module_base = base_module_path + else: + current_module_base = f"{base_module_path}.{path_diff.replace(os.sep, '.')}" + + # Dynamically import each module + for file_name in module_files: + module_name = file_name[:-3] # Remove the '.py' extension + full_module_path = f"{current_module_base}.{module_name}" + importlib.import_module(full_module_path) + +# Import all models before creating engine +import_all_models() + learnhouse_config = get_learnhouse_config() engine = create_engine( - learnhouse_config.database_config.sql_connection_string, echo=False, pool_pre_ping=True # type: ignore + learnhouse_config.database_config.sql_connection_string, # type: ignore + echo=False, + pool_pre_ping=True # type: ignore ) -SQLModel.metadata.create_all(engine) +# Create all tables after importing all models +SQLModel.metadata.create_all(engine) async def connect_to_db(app: FastAPI): app.db_engine = engine # type: ignore logging.info("LearnHouse database has been started.") SQLModel.metadata.create_all(engine) - def get_db_session(): with Session(engine) as session: yield session - async def close_database(app: FastAPI): logging.info("LearnHouse has been shut down.") return app diff --git a/apps/api/src/db/collections.py b/apps/api/src/db/collections.py index fb0b1e94..7fd3c524 100644 --- a/apps/api/src/db/collections.py +++ b/apps/api/src/db/collections.py @@ -30,7 +30,7 @@ class CollectionUpdate(CollectionBase): courses: Optional[list] name: Optional[str] public: Optional[bool] - description: Optional[str] + description: Optional[str] = "" class CollectionRead(CollectionBase): diff --git a/apps/api/src/db/organization_config.py b/apps/api/src/db/organization_config.py index 54f45484..a54e537e 100644 --- a/apps/api/src/db/organization_config.py +++ b/apps/api/src/db/organization_config.py @@ -40,7 +40,6 @@ class AssignmentOrgConfig(BaseModel): class PaymentOrgConfig(BaseModel): enabled: bool = True - stripe_key: str = "" class DiscussionOrgConfig(BaseModel): @@ -91,7 +90,7 @@ class OrgCloudConfig(BaseModel): # Main Config class OrganizationConfigBase(BaseModel): - config_version: str = "1.1" + config_version: str = "1.2" general: OrgGeneralConfig features: OrgFeatureConfig cloud: OrgCloudConfig diff --git a/apps/api/src/db/payments/payments.py b/apps/api/src/db/payments/payments.py new file mode 100644 index 00000000..595fc8cf --- /dev/null +++ b/apps/api/src/db/payments/payments.py @@ -0,0 +1,46 @@ +from datetime import datetime +from enum import Enum +from typing import Optional +from sqlalchemy import JSON +from sqlmodel import Field, SQLModel, Column, BigInteger, ForeignKey + +# PaymentsConfig +class PaymentProviderEnum(str, Enum): + STRIPE = "stripe" + +class PaymentsConfigBase(SQLModel): + enabled: bool = True + active: bool = False + provider: PaymentProviderEnum = PaymentProviderEnum.STRIPE + provider_specific_id: str | None = None + provider_config: dict = Field(default={}, sa_column=Column(JSON)) + + +class PaymentsConfig(PaymentsConfigBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + org_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE")) + ) + creation_date: datetime = Field(default=datetime.now()) + update_date: datetime = Field(default=datetime.now()) + + +class PaymentsConfigCreate(PaymentsConfigBase): + pass + + +class PaymentsConfigUpdate(PaymentsConfigBase): + enabled: Optional[bool] = True + provider_config: Optional[dict] = None + provider_specific_id: Optional[str] = None + + +class PaymentsConfigRead(PaymentsConfigBase): + id: int + org_id: int + creation_date: datetime + update_date: datetime + + +class PaymentsConfigDelete(SQLModel): + id: int \ No newline at end of file diff --git a/apps/api/src/db/payments/payments_courses.py b/apps/api/src/db/payments/payments_courses.py new file mode 100644 index 00000000..bdcfbead --- /dev/null +++ b/apps/api/src/db/payments/payments_courses.py @@ -0,0 +1,15 @@ +from sqlmodel import SQLModel, Field, Column, BigInteger, ForeignKey +from typing import Optional +from datetime import datetime + +class PaymentsCourseBase(SQLModel): + course_id: int = Field(sa_column=Column(BigInteger, ForeignKey("course.id", ondelete="CASCADE"))) + +class PaymentsCourse(PaymentsCourseBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + payment_product_id: int = Field(sa_column=Column(BigInteger, ForeignKey("paymentsproduct.id", ondelete="CASCADE"))) + org_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE")) + ) + creation_date: datetime = Field(default=datetime.now()) + update_date: datetime = Field(default=datetime.now()) \ No newline at end of file diff --git a/apps/api/src/db/payments/payments_products.py b/apps/api/src/db/payments/payments_products.py new file mode 100644 index 00000000..56cdcc73 --- /dev/null +++ b/apps/api/src/db/payments/payments_products.py @@ -0,0 +1,44 @@ +from enum import Enum +from sqlmodel import SQLModel, Field, Column, BigInteger, ForeignKey, String +from typing import Optional +from datetime import datetime + +class PaymentProductTypeEnum(str, Enum): + SUBSCRIPTION = "subscription" + ONE_TIME = "one_time" + +class PaymentPriceTypeEnum(str, Enum): + CUSTOMER_CHOICE = "customer_choice" + FIXED_PRICE = "fixed_price" + +class PaymentsProductBase(SQLModel): + name: str = "" + description: Optional[str] = "" + product_type: PaymentProductTypeEnum = PaymentProductTypeEnum.ONE_TIME + price_type: PaymentPriceTypeEnum = PaymentPriceTypeEnum.FIXED_PRICE + benefits: str = "" + amount: float = 0.0 + currency: str = "USD" + +class PaymentsProduct(PaymentsProductBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + org_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE")) + ) + payments_config_id: int = Field(sa_column=Column(BigInteger, ForeignKey("paymentsconfig.id", ondelete="CASCADE"))) + provider_product_id: str = Field(sa_column=Column(String)) + creation_date: datetime = Field(default=datetime.now()) + update_date: datetime = Field(default=datetime.now()) + +class PaymentsProductCreate(PaymentsProductBase): + pass + +class PaymentsProductUpdate(PaymentsProductBase): + pass + +class PaymentsProductRead(PaymentsProductBase): + id: int + org_id: int + payments_config_id: int + creation_date: datetime + update_date: datetime diff --git a/apps/api/src/db/payments/payments_users.py b/apps/api/src/db/payments/payments_users.py new file mode 100644 index 00000000..ce3b3f68 --- /dev/null +++ b/apps/api/src/db/payments/payments_users.py @@ -0,0 +1,37 @@ +from openai import BaseModel +from sqlmodel import SQLModel, Field, Column, BigInteger, ForeignKey, JSON +from typing import Optional +from datetime import datetime +from enum import Enum + +class PaymentStatusEnum(str, Enum): + PENDING = "pending" + COMPLETED = "completed" + ACTIVE = "active" + CANCELLED = "cancelled" + FAILED = "failed" + REFUNDED = "refunded" + + +class ProviderSpecificData(BaseModel): + stripe_customer: dict | None = None + custom_customer: dict | None = None + +class PaymentsUserBase(SQLModel): + status: PaymentStatusEnum = PaymentStatusEnum.PENDING + provider_specific_data: dict = Field(default={}, sa_column=Column(JSON)) + +class PaymentsUser(PaymentsUserBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("user.id", ondelete="CASCADE")) + ) + org_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE")) + ) + payment_product_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("paymentsproduct.id", ondelete="CASCADE")) + ) + creation_date: datetime = Field(default=datetime.now()) + update_date: datetime = Field(default=datetime.now()) + diff --git a/apps/api/src/db/users.py b/apps/api/src/db/users.py index 8d98c3e7..d7412e30 100644 --- a/apps/api/src/db/users.py +++ b/apps/api/src/db/users.py @@ -59,6 +59,11 @@ class AnonymousUser(SQLModel): user_uuid: str = "user_anonymous" username: str = "anonymous" +class InternalUser(SQLModel): + id: int = 0 + user_uuid: str = "user_internal" + username: str = "internal" + class User(UserBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) diff --git a/apps/api/src/router.py b/apps/api/src/router.py index bc4aaaa8..2725a263 100644 --- a/apps/api/src/router.py +++ b/apps/api/src/router.py @@ -1,11 +1,12 @@ import os from fastapi import APIRouter, Depends +from src.routers import health from src.routers import usergroups from src.routers import dev, trail, users, auth, orgs, roles from src.routers.ai import ai from src.routers.courses import chapters, collections, courses, assignments from src.routers.courses.activities import activities, blocks -from src.routers.ee import cloud_internal +from src.routers.ee import cloud_internal, payments from src.routers.install import install from src.services.dev.dev import isDevModeEnabledOrRaise from src.services.install.install import isInstallModeEnabled @@ -32,6 +33,7 @@ v1_router.include_router( ) v1_router.include_router(trail.router, prefix="/trail", tags=["trail"]) v1_router.include_router(ai.router, prefix="/ai", tags=["ai"]) +v1_router.include_router(payments.router, prefix="/payments", tags=["payments"]) if os.environ.get("CLOUD_INTERNAL_KEY"): v1_router.include_router( @@ -41,6 +43,8 @@ if os.environ.get("CLOUD_INTERNAL_KEY"): dependencies=[Depends(cloud_internal.check_internal_cloud_key)], ) +v1_router.include_router(health.router, prefix="/health", tags=["health"]) + # Dev Routes v1_router.include_router( dev.router, diff --git a/apps/api/src/routers/ee/payments.py b/apps/api/src/routers/ee/payments.py new file mode 100644 index 00000000..5b7f4ca2 --- /dev/null +++ b/apps/api/src/routers/ee/payments.py @@ -0,0 +1,265 @@ +from typing import Literal +from fastapi import APIRouter, Depends, Request +from sqlmodel import Session +from src.core.events.database import get_db_session +from src.db.payments.payments import PaymentsConfig, PaymentsConfigRead +from src.db.users import PublicUser +from src.security.auth import get_current_user +from src.services.payments.payments_config import ( + init_payments_config, + get_payments_config, + delete_payments_config, +) +from src.db.payments.payments_products import PaymentsProductCreate, PaymentsProductRead, PaymentsProductUpdate +from src.services.payments.payments_products import create_payments_product, delete_payments_product, get_payments_product, get_products_by_course, list_payments_products, update_payments_product +from src.services.payments.payments_courses import ( + link_course_to_product, + unlink_course_from_product, + get_courses_by_product, +) +from src.services.payments.payments_users import get_owned_courses +from src.services.payments.payments_stripe import create_checkout_session, handle_stripe_oauth_callback, update_stripe_account_id +from src.services.payments.payments_access import check_course_paid_access +from src.services.payments.payments_customers import get_customers +from src.services.payments.payments_stripe import generate_stripe_connect_link +from src.services.payments.webhooks.payments_webhooks import handle_stripe_webhook + + +router = APIRouter() + +@router.post("/{org_id}/config") +async def api_create_payments_config( + request: Request, + org_id: int, + provider: Literal["stripe"], + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> PaymentsConfig: + return await init_payments_config(request, org_id, provider, current_user, db_session) + + +@router.get("/{org_id}/config") +async def api_get_payments_config( + request: Request, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> list[PaymentsConfigRead]: + return await get_payments_config(request, org_id, current_user, db_session) + +@router.delete("/{org_id}/config") +async def api_delete_payments_config( + request: Request, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + await delete_payments_config(request, org_id, current_user, db_session) + return {"message": "Payments config deleted successfully"} + +@router.post("/{org_id}/products") +async def api_create_payments_product( + request: Request, + org_id: int, + payments_product: PaymentsProductCreate, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> PaymentsProductRead: + return await create_payments_product(request, org_id, payments_product, current_user, db_session) + +@router.get("/{org_id}/products") +async def api_get_payments_products( + request: Request, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> list[PaymentsProductRead]: + return await list_payments_products(request, org_id, current_user, db_session) + +@router.get("/{org_id}/products/{product_id}") +async def api_get_payments_product( + request: Request, + org_id: int, + product_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> PaymentsProductRead: + return await get_payments_product(request, org_id, product_id, current_user, db_session) + +@router.put("/{org_id}/products/{product_id}") +async def api_update_payments_product( + request: Request, + org_id: int, + product_id: int, + payments_product: PaymentsProductUpdate, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> PaymentsProductRead: + return await update_payments_product(request, org_id, product_id, payments_product, current_user, db_session) + +@router.delete("/{org_id}/products/{product_id}") +async def api_delete_payments_product( + request: Request, + org_id: int, + product_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + await delete_payments_product(request, org_id, product_id, current_user, db_session) + return {"message": "Payments product deleted successfully"} + +@router.post("/{org_id}/products/{product_id}/courses/{course_id}") +async def api_link_course_to_product( + request: Request, + org_id: int, + product_id: int, + course_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + return await link_course_to_product( + request, org_id, course_id, product_id, current_user, db_session + ) + +@router.delete("/{org_id}/products/{product_id}/courses/{course_id}") +async def api_unlink_course_from_product( + request: Request, + org_id: int, + product_id: int, + course_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + return await unlink_course_from_product( + request, org_id, course_id, current_user, db_session + ) + +@router.get("/{org_id}/products/{product_id}/courses") +async def api_get_courses_by_product( + request: Request, + org_id: int, + product_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + return await get_courses_by_product( + request, org_id, product_id, current_user, db_session + ) + +@router.get("/{org_id}/courses/{course_id}/products") +async def api_get_products_by_course( + request: Request, + org_id: int, + course_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + return await get_products_by_course( + request, org_id, course_id, current_user, db_session + ) + +# Payments webhooks + +@router.post("/stripe/webhook") +async def api_handle_connected_accounts_stripe_webhook( + request: Request, + db_session: Session = Depends(get_db_session), +): + return await handle_stripe_webhook(request, "standard", db_session) + +@router.post("/stripe/webhook/connect") +async def api_handle_connected_accounts_stripe_webhook_connect( + request: Request, + db_session: Session = Depends(get_db_session), +): + return await handle_stripe_webhook(request, "connect", db_session) + +# Payments checkout + +@router.post("/{org_id}/stripe/checkout/product/{product_id}") +async def api_create_checkout_session( + request: Request, + org_id: int, + product_id: int, + redirect_uri: str, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + return await create_checkout_session(request, org_id, product_id, redirect_uri, current_user, db_session) + +@router.get("/{org_id}/courses/{course_id}/access") +async def api_check_course_paid_access( + request: Request, + org_id: int, + course_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Check if current user has paid access to a specific course + """ + return { + "has_access": await check_course_paid_access( + course_id=course_id, + user=current_user, + db_session=db_session + ) + } + +@router.get("/{org_id}/customers") +async def api_get_customers( + request: Request, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Get list of customers and their subscriptions for an organization + """ + return await get_customers(request, org_id, current_user, db_session) + +@router.get("/{org_id}/courses/owned") +async def api_get_owned_courses( + request: Request, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + return await get_owned_courses(request, current_user, db_session) + +@router.put("/{org_id}/stripe/account") +async def api_update_stripe_account_id( + request: Request, + org_id: int, + stripe_account_id: str, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + return await update_stripe_account_id( + request, org_id, stripe_account_id, current_user, db_session + ) + +@router.post("/{org_id}/stripe/connect/link") +async def api_generate_stripe_connect_link( + request: Request, + org_id: int, + redirect_uri: str, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Generate a Stripe OAuth link for connecting a Stripe account + """ + return await generate_stripe_connect_link( + request, org_id, redirect_uri, current_user, db_session + ) + +@router.get("/stripe/oauth/callback") +async def stripe_oauth_callback( + request: Request, + code: str, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + return await handle_stripe_oauth_callback(request, org_id, code, current_user, db_session) \ No newline at end of file diff --git a/apps/api/src/routers/health.py b/apps/api/src/routers/health.py new file mode 100644 index 00000000..68d78588 --- /dev/null +++ b/apps/api/src/routers/health.py @@ -0,0 +1,11 @@ +from fastapi import Depends, APIRouter +from sqlmodel import Session +from src.services.health.health import check_health +from src.core.events.database import get_db_session + + +router = APIRouter() + +@router.get("") +async def health(db_session: Session = Depends(get_db_session)): + return await check_health(db_session) \ No newline at end of file diff --git a/apps/api/src/security/rbac/rbac.py b/apps/api/src/security/rbac/rbac.py index 04b64ca1..66a2dc89 100644 --- a/apps/api/src/security/rbac/rbac.py +++ b/apps/api/src/security/rbac/rbac.py @@ -142,7 +142,7 @@ async def authorization_verify_based_on_org_admin_status( # Tested and working -async def authorization_verify_based_on_roles_and_authorship_and_usergroups( +async def authorization_verify_based_on_roles_and_authorship( request: Request, user_id: int, action: Literal["read", "update", "delete", "create"], diff --git a/apps/api/src/services/courses/activities/activities.py b/apps/api/src/services/courses/activities/activities.py index 8b5c7d03..985a48af 100644 --- a/apps/api/src/services/courses/activities/activities.py +++ b/apps/api/src/services/courses/activities/activities.py @@ -3,7 +3,7 @@ from sqlmodel import Session, select from src.db.courses.courses import Course from src.db.courses.chapters import Chapter from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship_and_usergroups, + authorization_verify_based_on_roles_and_authorship, authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) @@ -14,6 +14,8 @@ from fastapi import HTTPException, Request from uuid import uuid4 from datetime import datetime +from src.services.payments.payments_access import check_activity_paid_access + #################################################### # CRUD @@ -112,7 +114,16 @@ async def get_activity( # RBAC check await rbac_check(request, course.course_uuid, current_user, "read", db_session) - activity = ActivityRead.model_validate(activity) + # Paid access check + has_paid_access = await check_activity_paid_access( + activity_id=activity.id if activity.id else 0, + user=current_user, + db_session=db_session + ) + + activity_read = ActivityRead.model_validate(activity) + activity_read.content = activity_read.content if has_paid_access else { "paid_access": False } + activity = activity_read return activity @@ -258,30 +269,32 @@ async def get_activities( async def rbac_check( request: Request, - course_uuid: str, + element_uuid: str, current_user: PublicUser | AnonymousUser, action: Literal["create", "read", "update", "delete"], db_session: Session, ): + + if action == "read": if current_user.id == 0: # Anonymous user res = await authorization_verify_if_element_is_public( - request, course_uuid, action, db_session + request, element_uuid, action, db_session ) return res else: - res = await authorization_verify_based_on_roles_and_authorship_and_usergroups( - request, current_user.id, action, course_uuid, db_session + res = await authorization_verify_based_on_roles_and_authorship( + request, current_user.id, action, element_uuid, db_session ) return res else: + # For non-read actions, proceed with regular RBAC checks await authorization_verify_if_user_is_anon(current_user.id) - - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, - course_uuid, + element_uuid, db_session, ) diff --git a/apps/api/src/services/courses/activities/assignments.py b/apps/api/src/services/courses/activities/assignments.py index 99b59b26..ae563e08 100644 --- a/apps/api/src/services/courses/activities/assignments.py +++ b/apps/api/src/services/courses/activities/assignments.py @@ -34,7 +34,7 @@ from src.security.features_utils.usage import ( increase_feature_usage, ) from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship_and_usergroups, + authorization_verify_based_on_roles_and_authorship, authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) @@ -1666,7 +1666,7 @@ async def rbac_check( return res else: res = ( - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, course_uuid, db_session ) ) @@ -1674,7 +1674,7 @@ async def rbac_check( else: await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, diff --git a/apps/api/src/services/courses/activities/pdf.py b/apps/api/src/services/courses/activities/pdf.py index 30b4db9d..e6f2ca51 100644 --- a/apps/api/src/services/courses/activities/pdf.py +++ b/apps/api/src/services/courses/activities/pdf.py @@ -3,7 +3,7 @@ from src.db.courses.courses import Course from src.db.organizations import Organization from sqlmodel import Session, select from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship_and_usergroups, + authorization_verify_based_on_roles_and_authorship, authorization_verify_if_user_is_anon, ) from src.db.courses.chapters import Chapter @@ -150,7 +150,7 @@ async def rbac_check( ): await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, diff --git a/apps/api/src/services/courses/activities/video.py b/apps/api/src/services/courses/activities/video.py index 3396607c..da428865 100644 --- a/apps/api/src/services/courses/activities/video.py +++ b/apps/api/src/services/courses/activities/video.py @@ -5,7 +5,7 @@ from src.db.organizations import Organization from pydantic import BaseModel from sqlmodel import Session, select from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship_and_usergroups, + authorization_verify_based_on_roles_and_authorship, authorization_verify_if_user_is_anon, ) from src.db.courses.chapters import Chapter @@ -232,7 +232,7 @@ async def rbac_check( ): await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, diff --git a/apps/api/src/services/courses/chapters.py b/apps/api/src/services/courses/chapters.py index fd34d842..5bc3e9bb 100644 --- a/apps/api/src/services/courses/chapters.py +++ b/apps/api/src/services/courses/chapters.py @@ -4,7 +4,7 @@ from uuid import uuid4 from sqlmodel import Session, select from src.db.users import AnonymousUser from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship_and_usergroups, + authorization_verify_based_on_roles_and_authorship, authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) @@ -561,14 +561,14 @@ async def rbac_check( ) return res else: - res = await authorization_verify_based_on_roles_and_authorship_and_usergroups( + res = await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, course_uuid, db_session ) return res else: await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, diff --git a/apps/api/src/services/courses/collections.py b/apps/api/src/services/courses/collections.py index 9c5f8412..d54faedf 100644 --- a/apps/api/src/services/courses/collections.py +++ b/apps/api/src/services/courses/collections.py @@ -4,7 +4,7 @@ from uuid import uuid4 from sqlmodel import Session, select from src.db.users import AnonymousUser from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship_and_usergroups, + authorization_verify_based_on_roles_and_authorship, authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) @@ -300,7 +300,7 @@ async def rbac_check( ) else: res = ( - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, collection_uuid, db_session ) ) @@ -308,7 +308,7 @@ async def rbac_check( else: await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index fa140944..2233d9c9 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -1,7 +1,6 @@ -from typing import Literal +from typing import Literal, List from uuid import uuid4 -from sqlalchemy import union -from sqlmodel import Session, select +from sqlmodel import Session, select, or_, and_ from src.db.usergroup_resources import UserGroupResource from src.db.usergroup_user import UserGroupUser from src.db.organizations import Organization @@ -21,7 +20,7 @@ from src.db.courses.courses import ( FullCourseReadWithTrail, ) from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship_and_usergroups, + authorization_verify_based_on_roles_and_authorship, authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) @@ -151,6 +150,69 @@ async def get_course_meta( trail=trail if trail else None, ) +async def get_courses_orgslug( + request: Request, + current_user: PublicUser | AnonymousUser, + org_slug: str, + db_session: Session, + page: int = 1, + limit: int = 10, +) -> List[CourseRead]: + offset = (page - 1) * limit + + # Base query + query = ( + select(Course) + .join(Organization) + .where(Organization.slug == org_slug) + ) + + if isinstance(current_user, AnonymousUser): + # For anonymous users, only show public courses + query = query.where(Course.public == True) + else: + # For authenticated users, show: + # 1. Public courses + # 2. Courses not in any UserGroup + # 3. Courses in UserGroups where the user is a member + # 4. Courses where the user is a resource author + query = ( + query + .outerjoin(UserGroupResource, UserGroupResource.resource_uuid == Course.course_uuid) # type: ignore + .outerjoin(UserGroupUser, and_( + UserGroupUser.usergroup_id == UserGroupResource.usergroup_id, + UserGroupUser.user_id == current_user.id + )) + .outerjoin(ResourceAuthor, ResourceAuthor.resource_uuid == Course.course_uuid) # type: ignore + .where(or_( + Course.public == True, + UserGroupResource.resource_uuid == None, # Courses not in any UserGroup # noqa: E711 + UserGroupUser.user_id == current_user.id, # Courses in UserGroups where user is a member + ResourceAuthor.user_id == current_user.id # Courses where user is a resource author + )) + ) + + # Apply pagination + query = query.offset(offset).limit(limit).distinct() + + courses = db_session.exec(query).all() + + # Fetch authors for each course + course_reads = [] + for course in courses: + authors_query = ( + select(User) + .join(ResourceAuthor, ResourceAuthor.user_id == User.id) # type: ignore + .where(ResourceAuthor.resource_uuid == course.course_uuid) + ) + authors = db_session.exec(authors_query).all() + + course_read = CourseRead.model_validate(course) + course_read.authors = [UserRead.model_validate(author) for author in authors] + course_reads.append(course_read) + + return course_reads + async def create_course( request: Request, @@ -366,72 +428,7 @@ async def delete_course( return {"detail": "Course deleted"} -async def get_courses_orgslug( - request: Request, - current_user: PublicUser | AnonymousUser, - org_slug: str, - db_session: Session, - page: int = 1, - limit: int = 10, -): - # TODO : This entire function is a mess. It needs to be rewritten. - - # Query for public courses - statement_public = ( - select(Course) - .join(Organization) - .where(Organization.slug == org_slug, Course.public == True) - ) - - # Query for courses where the current user is an author - statement_author = ( - select(Course) - .join(Organization) - .join(ResourceAuthor, ResourceAuthor.user_id == current_user.id) # type: ignore - .where( - Organization.slug == org_slug, - ResourceAuthor.resource_uuid == Course.course_uuid, - ) - ) - - # Query for courses where the current user is in a user group that has access to the course - statement_usergroup = ( - select(Course) - .join(Organization) - .join(UserGroupResource, UserGroupResource.resource_uuid == Course.course_uuid) # type: ignore - .join( - UserGroupUser, UserGroupUser.usergroup_id == UserGroupResource.usergroup_id # type: ignore - ) - .where(Organization.slug == org_slug, UserGroupUser.user_id == current_user.id) - ) - - # Combine the results - statement_complete = union( - statement_public, statement_author, statement_usergroup - ).subquery() - - # TODO: migrate this to exec - courses = db_session.execute(select(statement_complete)).all() # type: ignore - - # TODO: I have no idea why this is necessary, but it is - courses = [CourseRead(**course._asdict(), authors=[]) for course in courses] - - # for every course, get the authors - for course in courses: - authors_statement = ( - select(User) - .join(ResourceAuthor) - .where(ResourceAuthor.resource_uuid == course.course_uuid) - ) - authors = db_session.exec(authors_statement).all() - - # convert from User to UserRead - authors = [UserRead.model_validate(author) for author in authors] - - course.authors = authors - - return courses ## 🔒 RBAC Utils ## @@ -452,7 +449,7 @@ async def rbac_check( return res else: res = ( - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, course_uuid, db_session ) ) @@ -460,7 +457,7 @@ async def rbac_check( else: await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, diff --git a/apps/api/src/services/health/__init__.py b/apps/api/src/services/health/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/src/services/health/health.py b/apps/api/src/services/health/health.py new file mode 100644 index 00000000..8daccbd9 --- /dev/null +++ b/apps/api/src/services/health/health.py @@ -0,0 +1,21 @@ +from fastapi import HTTPException +from sqlmodel import Session, select +from src.db.organizations import Organization + +async def check_database_health(db_session: Session) -> bool: + statement = select(Organization) + result = db_session.exec(statement) + + if not result: + return False + + return True + +async def check_health(db_session: Session) -> bool: + # Check database health + database_healthy = await check_database_health(db_session) + + if not database_healthy: + raise HTTPException(status_code=503, detail="Database is not healthy") + + return True diff --git a/apps/api/src/services/install/install.py b/apps/api/src/services/install/install.py index 935caf80..84841fb4 100644 --- a/apps/api/src/services/install/install.py +++ b/apps/api/src/services/install/install.py @@ -330,7 +330,7 @@ def install_create_organization(org_object: OrganizationCreate, db_session: Sess # Org Config org_config = OrganizationConfigBase( - config_version="1.1", + config_version="1.2", general=OrgGeneralConfig( enabled=True, color="normal", @@ -345,7 +345,7 @@ def install_create_organization(org_object: OrganizationCreate, db_session: Sess storage=StorageOrgConfig(enabled=True, limit=0), ai=AIOrgConfig(enabled=True, limit=0, model="gpt-4o-mini"), assignments=AssignmentOrgConfig(enabled=True, limit=0), - payments=PaymentOrgConfig(enabled=True, stripe_key=""), + payments=PaymentOrgConfig(enabled=False), discussions=DiscussionOrgConfig(enabled=True, limit=0), analytics=AnalyticsOrgConfig(enabled=True, limit=0), collaboration=CollaborationOrgConfig(enabled=True, limit=0), diff --git a/apps/api/src/services/orgs/orgs.py b/apps/api/src/services/orgs/orgs.py index c7e92000..13c084ed 100644 --- a/apps/api/src/services/orgs/orgs.py +++ b/apps/api/src/services/orgs/orgs.py @@ -26,7 +26,7 @@ from src.security.rbac.rbac import ( authorization_verify_based_on_org_admin_status, authorization_verify_if_user_is_anon, ) -from src.db.users import AnonymousUser, PublicUser +from src.db.users import AnonymousUser, InternalUser, PublicUser from src.db.user_organizations import UserOrganization from src.db.organizations import ( Organization, @@ -682,13 +682,17 @@ async def get_org_join_mechanism( async def rbac_check( request: Request, org_uuid: str, - current_user: PublicUser | AnonymousUser, + current_user: PublicUser | AnonymousUser | InternalUser, action: Literal["create", "read", "update", "delete"], db_session: Session, ): # Organizations are readable by anyone if action == "read": return True + + # Internal users can do anything + if isinstance(current_user, InternalUser): + return True else: isUserAnon = await authorization_verify_if_user_is_anon(current_user.id) diff --git a/apps/api/src/services/payments/payments_access.py b/apps/api/src/services/payments/payments_access.py new file mode 100644 index 00000000..6f2632ac --- /dev/null +++ b/apps/api/src/services/payments/payments_access.py @@ -0,0 +1,98 @@ +from sqlmodel import Session, select +from src.db.payments.payments_users import PaymentStatusEnum, PaymentsUser +from src.db.users import PublicUser, AnonymousUser +from src.db.payments.payments_courses import PaymentsCourse +from src.db.courses.activities import Activity +from src.db.courses.courses import Course +from fastapi import HTTPException + +async def check_activity_paid_access( + activity_id: int, + user: PublicUser | AnonymousUser, + db_session: Session, +) -> bool: + """ + Check if a user has access to a specific activity + Returns True if: + - User is an author of the course + - Activity is in a free course + - User has a valid subscription for the course + """ + + + # Get activity and associated course + statement = select(Activity).where(Activity.id == activity_id) + activity = db_session.exec(statement).first() + + if not activity: + raise HTTPException(status_code=404, detail="Activity not found") + + # Check if course exists + statement = select(Course).where(Course.id == activity.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException(status_code=404, detail="Course not found") + + # Check if course is linked to a product + statement = select(PaymentsCourse).where(PaymentsCourse.course_id == course.id) + course_payment = db_session.exec(statement).first() + + # If course is not linked to any product, it's free + if not course_payment: + return True + + # Anonymous users have no access to paid activities + if isinstance(user, AnonymousUser): + return False + + # Check if user has a valid subscription or payment + statement = select(PaymentsUser).where( + PaymentsUser.user_id == user.id, + PaymentsUser.payment_product_id == course_payment.payment_product_id, + PaymentsUser.status.in_( # type: ignore + [PaymentStatusEnum.ACTIVE, PaymentStatusEnum.COMPLETED] + ), + ) + access = db_session.exec(statement).first() + + return bool(access) + +async def check_course_paid_access( + course_id: int, + user: PublicUser | AnonymousUser, + db_session: Session, +) -> bool: + """ + Check if a user has paid access to a specific course + Returns True if: + - User is an author of the course + - Course is free (not linked to any product) + - User has a valid subscription for the course + """ + # Check if course exists + statement = select(Course).where(Course.id == course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException(status_code=404, detail="Course not found") + + # Check if course is linked to a product + statement = select(PaymentsCourse).where(PaymentsCourse.course_id == course.id) + course_payment = db_session.exec(statement).first() + + # If course is not linked to any product, it's free + if not course_payment: + return True + + # Check if user has a valid subscription + statement = select(PaymentsUser).where( + PaymentsUser.user_id == user.id, + PaymentsUser.payment_product_id == course_payment.payment_product_id, + PaymentsUser.status.in_( # type: ignore + [PaymentStatusEnum.ACTIVE, PaymentStatusEnum.COMPLETED] + ), + ) + subscription = db_session.exec(statement).first() + + return bool(subscription) diff --git a/apps/api/src/services/payments/payments_config.py b/apps/api/src/services/payments/payments_config.py new file mode 100644 index 00000000..35c60640 --- /dev/null +++ b/apps/api/src/services/payments/payments_config.py @@ -0,0 +1,139 @@ +from typing import Literal +from fastapi import HTTPException, Request +from sqlmodel import Session, select +from src.db.payments.payments import ( + PaymentProviderEnum, + PaymentsConfig, + PaymentsConfigUpdate, + PaymentsConfigRead, +) +from src.db.users import PublicUser, AnonymousUser, InternalUser +from src.db.organizations import Organization +from src.services.orgs.orgs import rbac_check + + +async def init_payments_config( + request: Request, + org_id: int, + provider: Literal["stripe"], + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> PaymentsConfig: + # Validate organization exists + org = db_session.exec( + select(Organization).where(Organization.id == org_id) + ).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # Verify permissions + await rbac_check(request, org.org_uuid, current_user, "create", db_session) + + # Check for existing config + existing_config = db_session.exec( + select(PaymentsConfig).where(PaymentsConfig.org_id == org_id) + ).first() + + if existing_config: + raise HTTPException( + status_code=409, + detail="Payments config already exists for this organization" + ) + + # Initialize new config + new_config = PaymentsConfig( + org_id=org_id, + provider=PaymentProviderEnum.STRIPE, + provider_config={ + "onboarding_completed": False, + }, + provider_specific_id=None + ) + + # Save to database + db_session.add(new_config) + db_session.commit() + db_session.refresh(new_config) + + return new_config + + +async def get_payments_config( + request: Request, + org_id: int, + current_user: PublicUser | AnonymousUser | InternalUser, + db_session: Session, +) -> list[PaymentsConfigRead]: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + + # Get payments config + statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id) + configs = db_session.exec(statement).all() + + return [PaymentsConfigRead.model_validate(config) for config in configs] + + +async def update_payments_config( + request: Request, + org_id: int, + payments_config: PaymentsConfigUpdate, + current_user: PublicUser | AnonymousUser | InternalUser, + db_session: Session, +) -> PaymentsConfig: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "update", db_session) + + # Get existing payments config + statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id) + config = db_session.exec(statement).first() + if not config: + raise HTTPException(status_code=404, detail="Payments config not found") + + # Update config + for key, value in payments_config.model_dump().items(): + setattr(config, key, value) + + db_session.add(config) + db_session.commit() + db_session.refresh(config) + + return config + + +async def delete_payments_config( + request: Request, + org_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> None: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "delete", db_session) + + # Get existing payments config + statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id) + config = db_session.exec(statement).first() + if not config: + raise HTTPException(status_code=404, detail="Payments config not found") + + # Delete config + db_session.delete(config) + db_session.commit() diff --git a/apps/api/src/services/payments/payments_courses.py b/apps/api/src/services/payments/payments_courses.py new file mode 100644 index 00000000..1382e408 --- /dev/null +++ b/apps/api/src/services/payments/payments_courses.py @@ -0,0 +1,123 @@ +from fastapi import HTTPException, Request +from sqlmodel import Session, select +from src.db.payments.payments_courses import PaymentsCourse +from src.db.payments.payments_products import PaymentsProduct +from src.db.courses.courses import Course +from src.db.users import PublicUser, AnonymousUser +from src.services.courses.courses import rbac_check + +async def link_course_to_product( + request: Request, + org_id: int, + course_id: int, + product_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if course exists and user has permission + statement = select(Course).where(Course.id == course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException(status_code=404, detail="Course not found") + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) + + # Check if product exists + statement = select(PaymentsProduct).where( + PaymentsProduct.id == product_id, + PaymentsProduct.org_id == org_id + ) + product = db_session.exec(statement).first() + + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + # Check if course is already linked to another product + statement = select(PaymentsCourse).where(PaymentsCourse.course_id == course.id) + existing_link = db_session.exec(statement).first() + + if existing_link: + raise HTTPException( + status_code=400, + detail="Course is already linked to a product" + ) + + # Create new payment course link + payment_course = PaymentsCourse( + course_id=course.id, # type: ignore + payment_product_id=product_id, + org_id=org_id, + ) + + db_session.add(payment_course) + db_session.commit() + + return {"message": "Course linked to product successfully"} + +async def unlink_course_from_product( + request: Request, + org_id: int, + course_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if course exists and user has permission + statement = select(Course).where(Course.id == course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException(status_code=404, detail="Course not found") + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) + + # Find and delete the payment course link + statement = select(PaymentsCourse).where( + PaymentsCourse.course_id == course.id, + PaymentsCourse.org_id == org_id + ) + payment_course = db_session.exec(statement).first() + + if not payment_course: + raise HTTPException( + status_code=404, + detail="Course is not linked to any product" + ) + + db_session.delete(payment_course) + db_session.commit() + + return {"message": "Course unlinked from product successfully"} + +async def get_courses_by_product( + request: Request, + org_id: int, + product_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if product exists + statement = select(PaymentsProduct).where( + PaymentsProduct.id == product_id, + PaymentsProduct.org_id == org_id + ) + product = db_session.exec(statement).first() + + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + # Get all courses linked to this product with explicit join + statement = ( + select(Course) + .select_from(Course) + .join(PaymentsCourse, Course.id == PaymentsCourse.course_id) # type: ignore + .where( + PaymentsCourse.payment_product_id == product_id, + PaymentsCourse.org_id == org_id + ) + ) + courses = db_session.exec(statement).all() + + return courses diff --git a/apps/api/src/services/payments/payments_customers.py b/apps/api/src/services/payments/payments_customers.py new file mode 100644 index 00000000..3922bb0e --- /dev/null +++ b/apps/api/src/services/payments/payments_customers.py @@ -0,0 +1,50 @@ +from fastapi import HTTPException, Request +from sqlmodel import Session, select +from src.db.organizations import Organization +from src.db.users import PublicUser, AnonymousUser +from src.db.payments.payments_users import PaymentsUser +from src.services.orgs.orgs import rbac_check +from src.services.payments.payments_products import get_payments_product +from src.services.users.users import read_user_by_id + +async def get_customers( + request: Request, + org_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + + # Get all payment users for the organization + statement = select(PaymentsUser).where(PaymentsUser.org_id == org_id) + payment_users = db_session.exec(statement).all() + + customers_data = [] + + for payment_user in payment_users: + # Get user data + user = await read_user_by_id(request, db_session, current_user, payment_user.user_id) + + # Get product data + if org.id is None: + raise HTTPException(status_code=400, detail="Invalid organization ID") + product = await get_payments_product(request, org.id, payment_user.payment_product_id, current_user, db_session) + + customer_data = { + 'payment_user_id': payment_user.id, + 'user': user if user else None, + 'product': product if product else None, + 'status': payment_user.status, + 'creation_date': payment_user.creation_date, + 'update_date': payment_user.update_date + } + customers_data.append(customer_data) + + return customers_data \ No newline at end of file diff --git a/apps/api/src/services/payments/payments_products.py b/apps/api/src/services/payments/payments_products.py new file mode 100644 index 00000000..81874e80 --- /dev/null +++ b/apps/api/src/services/payments/payments_products.py @@ -0,0 +1,216 @@ +from fastapi import HTTPException, Request +from sqlmodel import Session, select +from src.db.courses.courses import Course +from src.db.payments.payments import PaymentsConfig +from src.db.payments.payments_courses import PaymentsCourse +from src.db.payments.payments_products import ( + PaymentsProduct, + PaymentsProductCreate, + PaymentsProductUpdate, + PaymentsProductRead, +) +from src.db.payments.payments_users import PaymentStatusEnum, PaymentsUser +from src.db.users import PublicUser, AnonymousUser +from src.db.organizations import Organization +from src.services.orgs.orgs import rbac_check +from datetime import datetime + +from src.services.payments.payments_stripe import archive_stripe_product, create_stripe_product, update_stripe_product + +async def create_payments_product( + request: Request, + org_id: int, + payments_product: PaymentsProductCreate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> PaymentsProductRead: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "create", db_session) + + # Check if payments config exists, has a valid id, and is active + statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id) + config = db_session.exec(statement).first() + if not config or config.id is None: + raise HTTPException(status_code=404, detail="Valid payments config not found") + + if not config.active: + raise HTTPException(status_code=400, detail="Payments config is not active") + + # Create new payments product + new_product = PaymentsProduct(**payments_product.model_dump(), org_id=org_id, payments_config_id=config.id) + new_product.creation_date = datetime.now() + new_product.update_date = datetime.now() + + # Create product in Stripe + stripe_product = await create_stripe_product(request, org_id, new_product, current_user, db_session) + new_product.provider_product_id = stripe_product.id + + # Save to DB + db_session.add(new_product) + db_session.commit() + db_session.refresh(new_product) + + return PaymentsProductRead.model_validate(new_product) + +async def get_payments_product( + request: Request, + org_id: int, + product_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> PaymentsProductRead: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + + # Get payments product + statement = select(PaymentsProduct).where(PaymentsProduct.id == product_id, PaymentsProduct.org_id == org_id) + product = db_session.exec(statement).first() + if not product: + raise HTTPException(status_code=404, detail="Payments product not found") + + return PaymentsProductRead.model_validate(product) + +async def update_payments_product( + request: Request, + org_id: int, + product_id: int, + payments_product: PaymentsProductUpdate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> PaymentsProductRead: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "update", db_session) + + # Get existing payments product + statement = select(PaymentsProduct).where(PaymentsProduct.id == product_id, PaymentsProduct.org_id == org_id) + product = db_session.exec(statement).first() + if not product: + raise HTTPException(status_code=404, detail="Payments product not found") + + # Update product + for key, value in payments_product.model_dump().items(): + setattr(product, key, value) + + product.update_date = datetime.now() + + db_session.add(product) + db_session.commit() + db_session.refresh(product) + + # Update product in Stripe + await update_stripe_product(request, org_id, product.provider_product_id, product, current_user, db_session) + + return PaymentsProductRead.model_validate(product) + +async def delete_payments_product( + request: Request, + org_id: int, + product_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> None: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "delete", db_session) + + # Get existing payments product + statement = select(PaymentsProduct).where(PaymentsProduct.id == product_id, PaymentsProduct.org_id == org_id) + product = db_session.exec(statement).first() + if not product: + raise HTTPException(status_code=404, detail="Payments product not found") + + # Check if there are any payment users linked to this product + statement = select(PaymentsUser).where( + PaymentsUser.payment_product_id == product_id, + PaymentsUser.status.in_([PaymentStatusEnum.ACTIVE, PaymentStatusEnum.COMPLETED]) # type: ignore + ) + payment_users = db_session.exec(statement).all() + if payment_users: + raise HTTPException( + status_code=400, + detail="Cannot delete product because users have paid access to it." + ) + + # Archive product in Stripe + await archive_stripe_product(request, org_id, product.provider_product_id, current_user, db_session) + + # Delete product + db_session.delete(product) + db_session.commit() + +async def list_payments_products( + request: Request, + org_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> list[PaymentsProductRead]: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + + # Get payments products ordered by id + statement = select(PaymentsProduct).where(PaymentsProduct.org_id == org_id).order_by(PaymentsProduct.id.desc()) # type: ignore + products = db_session.exec(statement).all() + + return [PaymentsProductRead.model_validate(product) for product in products] + +async def get_products_by_course( + request: Request, + org_id: int, + course_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> list[PaymentsProductRead]: + # Check if course exists and user has permission + statement = select(Course).where(Course.id == course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException(status_code=404, detail="Course not found") + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + + # Get all products linked to this course with explicit join + statement = ( + select(PaymentsProduct) + .select_from(PaymentsProduct) + .join(PaymentsCourse, PaymentsProduct.id == PaymentsCourse.payment_product_id) # type: ignore + .where( + PaymentsCourse.course_id == course_id, + PaymentsCourse.org_id == org_id + ) + ) + products = db_session.exec(statement).all() + + return [PaymentsProductRead.model_validate(product) for product in products] + + diff --git a/apps/api/src/services/payments/payments_stripe.py b/apps/api/src/services/payments/payments_stripe.py new file mode 100644 index 00000000..536fac92 --- /dev/null +++ b/apps/api/src/services/payments/payments_stripe.py @@ -0,0 +1,473 @@ +import logging +from typing import Literal +from fastapi import HTTPException, Request +from sqlmodel import Session +import stripe +from config.config import get_learnhouse_config +from src.db.payments.payments import PaymentsConfigUpdate, PaymentsConfig +from src.db.payments.payments_products import ( + PaymentPriceTypeEnum, + PaymentProductTypeEnum, + PaymentsProduct, +) +from src.db.payments.payments_users import PaymentStatusEnum +from src.db.users import AnonymousUser, InternalUser, PublicUser +from src.services.payments.payments_config import ( + get_payments_config, + update_payments_config, +) +from sqlmodel import select + +from src.services.payments.payments_users import ( + create_payment_user, + delete_payment_user, +) + + +async def get_stripe_connected_account_id( + request: Request, + org_id: int, + current_user: PublicUser | AnonymousUser | InternalUser, + db_session: Session, +): + # Get payments config + payments_config = await get_payments_config(request, org_id, current_user, db_session) + + return payments_config[0].provider_specific_id + + +async def get_stripe_internal_credentials( +): + # Get payments config from config file + learnhouse_config = get_learnhouse_config() + + if not learnhouse_config.payments_config.stripe.stripe_secret_key: + raise HTTPException(status_code=400, detail="Stripe secret key not configured") + + if not learnhouse_config.payments_config.stripe.stripe_publishable_key: + raise HTTPException( + status_code=400, detail="Stripe publishable key not configured" + ) + + return { + "stripe_secret_key": learnhouse_config.payments_config.stripe.stripe_secret_key, + "stripe_publishable_key": learnhouse_config.payments_config.stripe.stripe_publishable_key, + "stripe_webhook_standard_secret": learnhouse_config.payments_config.stripe.stripe_webhook_standard_secret, + "stripe_webhook_connect_secret": learnhouse_config.payments_config.stripe.stripe_webhook_connect_secret, + } + + +async def create_stripe_product( + request: Request, + org_id: int, + product_data: PaymentsProduct, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + creds = await get_stripe_internal_credentials() + + # Set the Stripe API key using the credentials + stripe.api_key = creds.get("stripe_secret_key") + + # Prepare default_price_data based on price_type + if product_data.price_type == PaymentPriceTypeEnum.CUSTOMER_CHOICE: + default_price_data = { + "currency": product_data.currency, + "custom_unit_amount": { + "enabled": True, + "minimum": int(product_data.amount * 100), # Convert to cents + }, + } + else: + default_price_data = { + "currency": product_data.currency, + "unit_amount": int(product_data.amount * 100), # Convert to cents + } + + if product_data.product_type == PaymentProductTypeEnum.SUBSCRIPTION: + default_price_data["recurring"] = {"interval": "month"} + + stripe_acc_id = await get_stripe_connected_account_id(request, org_id, current_user, db_session) + + product = stripe.Product.create( + name=product_data.name, + description=product_data.description or "", + marketing_features=[ + {"name": benefit.strip()} + for benefit in product_data.benefits.split(",") + if benefit.strip() + ], + default_price_data=default_price_data, # type: ignore + stripe_account=stripe_acc_id, + ) + + return product + + +async def archive_stripe_product( + request: Request, + org_id: int, + product_id: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + creds = await get_stripe_internal_credentials() + + # Set the Stripe API key using the credentials + stripe.api_key = creds.get("stripe_secret_key") + + stripe_acc_id = await get_stripe_connected_account_id(request, org_id, current_user, db_session) + + try: + # Archive the product in Stripe + archived_product = stripe.Product.modify(product_id, active=False, stripe_account=stripe_acc_id) + + return archived_product + except stripe.StripeError as e: + print(f"Error archiving Stripe product: {str(e)}") + raise HTTPException( + status_code=400, detail=f"Error archiving Stripe product: {str(e)}" + ) + + +async def update_stripe_product( + request: Request, + org_id: int, + product_id: str, + product_data: PaymentsProduct, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + creds = await get_stripe_internal_credentials() + + # Set the Stripe API key using the credentials + stripe.api_key = creds.get("stripe_secret_key") + + stripe_acc_id = await get_stripe_connected_account_id(request, org_id, current_user, db_session) + + try: + # Create new price based on price_type + if product_data.price_type == PaymentPriceTypeEnum.CUSTOMER_CHOICE: + new_price_data = { + "currency": product_data.currency, + "product": product_id, + "custom_unit_amount": { + "enabled": True, + "minimum": int(product_data.amount * 100), # Convert to cents + }, + } + else: + new_price_data = { + "currency": product_data.currency, + "unit_amount": int(product_data.amount * 100), # Convert to cents + "product": product_id, + } + + if product_data.product_type == PaymentProductTypeEnum.SUBSCRIPTION: + new_price_data["recurring"] = {"interval": "month"} + + new_price = stripe.Price.create(**new_price_data) + + # Prepare the update data + update_data = { + "name": product_data.name, + "description": product_data.description or "", + "metadata": {"benefits": product_data.benefits}, + "marketing_features": [ + {"name": benefit.strip()} + for benefit in product_data.benefits.split(",") + if benefit.strip() + ], + "default_price": new_price.id, + } + + # Update the product in Stripe + updated_product = stripe.Product.modify(product_id, **update_data, stripe_account=stripe_acc_id) + + # Archive all existing prices for the product + existing_prices = stripe.Price.list(product=product_id, active=True) + for price in existing_prices: + if price.id != new_price.id: + stripe.Price.modify(price.id, active=False, stripe_account=stripe_acc_id) + + return updated_product + except stripe.StripeError as e: + raise HTTPException( + status_code=400, detail=f"Error updating Stripe product: {str(e)}" + ) + + +async def create_checkout_session( + request: Request, + org_id: int, + product_id: int, + redirect_uri: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Get Stripe credentials + creds = await get_stripe_internal_credentials() + stripe.api_key = creds.get("stripe_secret_key") + + + stripe_acc_id = await get_stripe_connected_account_id(request, org_id, current_user, db_session) + + # Get product details + statement = select(PaymentsProduct).where( + PaymentsProduct.id == product_id, PaymentsProduct.org_id == org_id + ) + product = db_session.exec(statement).first() + + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + success_url = redirect_uri + cancel_url = redirect_uri + + # Get the default price for the product + stripe_product = stripe.Product.retrieve(product.provider_product_id, stripe_account=stripe_acc_id) + line_items = [{"price": stripe_product.default_price, "quantity": 1}] + + + # Create or retrieve Stripe customer + try: + customers = stripe.Customer.list( + email=current_user.email, stripe_account=stripe_acc_id + ) + if customers.data: + customer = customers.data[0] + else: + customer = stripe.Customer.create( + email=current_user.email, + metadata={ + "user_id": str(current_user.id), + "org_id": str(org_id), + }, + stripe_account=stripe_acc_id, + ) + + # Create initial payment user with pending status + payment_user = await create_payment_user( + request=request, + org_id=org_id, + user_id=current_user.id, + product_id=product_id, + status=PaymentStatusEnum.PENDING, + provider_data=customer, + current_user=InternalUser(), + db_session=db_session, + ) + + if not payment_user: + raise HTTPException(status_code=400, detail="Error creating payment user") + + except stripe.StripeError as e: + # Clean up payment user if customer creation fails + if payment_user and payment_user.id: + await delete_payment_user( + request, org_id, payment_user.id, InternalUser(), db_session + ) + raise HTTPException( + status_code=400, detail=f"Error creating/retrieving customer: {str(e)}" + ) + + # Create checkout session with customer + try: + checkout_session_params = { + "success_url": success_url, + "cancel_url": cancel_url, + "mode": ( + "payment" + if product.product_type == PaymentProductTypeEnum.ONE_TIME + else "subscription" + ), + "line_items": line_items, + "customer": customer.id, + "metadata": { + "product_id": str(product.id), + "payment_user_id": str(payment_user.id), + } + } + + # Add payment_intent_data only for one-time payments + if product.product_type == PaymentProductTypeEnum.ONE_TIME: + checkout_session_params["payment_intent_data"] = { + "metadata": { + "product_id": str(product.id), + "payment_user_id": str(payment_user.id), + } + } + # Add subscription_data for subscription payments + else: + checkout_session_params["subscription_data"] = { + "metadata": { + "product_id": str(product.id), + "payment_user_id": str(payment_user.id), + } + } + + checkout_session = stripe.checkout.Session.create(**checkout_session_params, stripe_account=stripe_acc_id) + + return {"checkout_url": checkout_session.url, "session_id": checkout_session.id} + + except stripe.StripeError as e: + # Clean up payment user if checkout session creation fails + if payment_user and payment_user.id: + await delete_payment_user( + request, org_id, payment_user.id, InternalUser(), db_session + ) + logging.error(f"Error creating checkout session: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + + +async def generate_stripe_connect_link( + request: Request, + org_id: int, + redirect_uri: str, + current_user: PublicUser | AnonymousUser | InternalUser, + db_session: Session, +): + """ + Generate a Stripe OAuth link for connecting a Stripe account + """ + # Get credentials + creds = await get_stripe_internal_credentials() + stripe.api_key = creds.get("stripe_secret_key") + + # Get learnhouse config for client_id + learnhouse_config = get_learnhouse_config() + client_id = learnhouse_config.payments_config.stripe.stripe_client_id + + if not client_id: + raise HTTPException(status_code=400, detail="Stripe client ID not configured") + + state = f"org_id={org_id}" + + # Generate OAuth link for existing accounts + oauth_link = f"https://connect.stripe.com/oauth/authorize?response_type=code&client_id={client_id}&scope=read_write&redirect_uri={redirect_uri}&state={state}" + + return {"connect_url": oauth_link} + +async def create_stripe_account( + request: Request, + org_id: int, + type: Literal["standard"], # Only standard is supported for now, we'll see if we need express later + current_user: PublicUser | AnonymousUser | InternalUser, + db_session: Session, +): + # Get credentials + creds = await get_stripe_internal_credentials() + stripe.api_key = creds.get("stripe_secret_key") + + # Get existing payments config + statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id) + existing_config = db_session.exec(statement).first() + + if existing_config and existing_config.provider_specific_id: + logging.error(f"A Stripe Account is already linked to this organization: {existing_config.provider_specific_id}") + return existing_config.provider_specific_id + + # Create Stripe account + stripe_account = stripe.Account.create( + type="standard", + capabilities={ + "card_payments": {"requested": True}, + "transfers": {"requested": True}, + }, + ) + + config_data = existing_config.model_dump() if existing_config else {} + config_data.update({ + "enabled": True, + "provider_specific_id": stripe_account.id, # Use the ID directly + "provider_config": {"onboarding_completed": False} + }) + + # Update payments config for the org + await update_payments_config( + request, + org_id, + PaymentsConfigUpdate(**config_data), + current_user, + db_session, + ) + + return stripe_account + + +async def update_stripe_account_id( + request: Request, + org_id: int, + stripe_account_id: str, + current_user: PublicUser | AnonymousUser | InternalUser, + db_session: Session, +): + """ + Update the Stripe account ID for an organization + """ + # Get existing payments config + statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id) + existing_config = db_session.exec(statement).first() + + if not existing_config: + raise HTTPException( + status_code=404, + detail="No payments configuration found for this organization" + ) + + # Create config update with existing values but new stripe account id + config_data = existing_config.model_dump() + config_data["provider_specific_id"] = stripe_account_id + + # Update payments config + await update_payments_config( + request, + org_id, + PaymentsConfigUpdate(**config_data), + current_user, + db_session, + ) + + return {"message": "Stripe account ID updated successfully"} + +async def handle_stripe_oauth_callback( + request: Request, + org_id: int, + code: str, + current_user: PublicUser | AnonymousUser | InternalUser, + db_session: Session, +): + """ + Handle the OAuth callback from Stripe and complete the account connection + """ + creds = await get_stripe_internal_credentials() + stripe.api_key = creds.get("stripe_secret_key") + + try: + # Exchange the authorization code for an access token + response = stripe.OAuth.token( + grant_type='authorization_code', + code=code, + ) + + connected_account_id = response.stripe_user_id + if not connected_account_id: + raise HTTPException(status_code=400, detail="No account ID received from Stripe") + + # Now connected_account_id is guaranteed to be a string + await update_stripe_account_id( + request, + org_id, + connected_account_id, + current_user, + db_session, + ) + + return {"success": True, "account_id": connected_account_id} + + except stripe.StripeError as e: + logging.error(f"Error connecting Stripe account: {str(e)}") + raise HTTPException( + status_code=400, + detail=f"Error connecting Stripe account: {str(e)}" + ) diff --git a/apps/api/src/services/payments/payments_users.py b/apps/api/src/services/payments/payments_users.py new file mode 100644 index 00000000..16af55a6 --- /dev/null +++ b/apps/api/src/services/payments/payments_users.py @@ -0,0 +1,250 @@ +from fastapi import HTTPException, Request +from sqlmodel import Session, select +from typing import Any +from src.db.courses.courses import Course, CourseRead +from src.db.payments.payments_courses import PaymentsCourse +from src.db.payments.payments_users import PaymentsUser, PaymentStatusEnum, ProviderSpecificData +from src.db.payments.payments_products import PaymentsProduct +from src.db.resource_authors import ResourceAuthor +from src.db.users import InternalUser, PublicUser, AnonymousUser, User, UserRead +from src.db.organizations import Organization +from src.services.orgs.orgs import rbac_check +from datetime import datetime + +async def create_payment_user( + request: Request, + org_id: int, + user_id: int, + product_id: int, + status: PaymentStatusEnum, + provider_data: Any, + current_user: PublicUser | AnonymousUser | InternalUser, + db_session: Session, +) -> PaymentsUser: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "create", db_session) + + # Check if product exists + statement = select(PaymentsProduct).where( + PaymentsProduct.id == product_id, + PaymentsProduct.org_id == org_id + ) + product = db_session.exec(statement).first() + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + provider_specific_data = ProviderSpecificData( + stripe_customer=provider_data if provider_data else None, + ) + + # Check if user already has a payment user for this product + statement = select(PaymentsUser).where( + PaymentsUser.user_id == user_id, + PaymentsUser.org_id == org_id, + PaymentsUser.payment_product_id == product_id + ) + existing_payment_user = db_session.exec(statement).first() + + if existing_payment_user: + # If status is PENDING, CANCELLED, or FAILED, delete the existing record + if existing_payment_user.status in [ + PaymentStatusEnum.PENDING, + PaymentStatusEnum.CANCELLED, + PaymentStatusEnum.FAILED + ]: + db_session.delete(existing_payment_user) + db_session.commit() + else: + raise HTTPException(status_code=400, detail="User already has purchase for this product") + + # Create new payment user + payment_user = PaymentsUser( + user_id=user_id, + org_id=org_id, + payment_product_id=product_id, + provider_specific_data=provider_specific_data.model_dump(), + status=status + ) + + db_session.add(payment_user) + db_session.commit() + db_session.refresh(payment_user) + + return payment_user + +async def get_payment_user( + request: Request, + org_id: int, + payment_user_id: int, + current_user: PublicUser | AnonymousUser | InternalUser, + db_session: Session, +) -> PaymentsUser: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + + # Get payment user + statement = select(PaymentsUser).where( + PaymentsUser.id == payment_user_id, + PaymentsUser.org_id == org_id + ) + payment_user = db_session.exec(statement).first() + if not payment_user: + raise HTTPException(status_code=404, detail="Payment user not found") + + return payment_user + +async def update_payment_user_status( + request: Request, + org_id: int, + payment_user_id: int, + status: PaymentStatusEnum, + current_user: PublicUser | AnonymousUser | InternalUser, + db_session: Session, +) -> PaymentsUser: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "update", db_session) + + # Get existing payment user + statement = select(PaymentsUser).where( + PaymentsUser.id == payment_user_id, + PaymentsUser.org_id == org_id + ) + payment_user = db_session.exec(statement).first() + if not payment_user: + raise HTTPException(status_code=404, detail="Payment user not found") + + # Update status + payment_user.status = status + payment_user.update_date = datetime.now() + + db_session.add(payment_user) + db_session.commit() + db_session.refresh(payment_user) + + return payment_user + +async def list_payment_users( + request: Request, + org_id: int, + current_user: PublicUser | AnonymousUser | InternalUser, + db_session: Session, +) -> list[PaymentsUser]: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + + # Get all payment users for org ordered by id + statement = select(PaymentsUser).where( + PaymentsUser.org_id == org_id + ).order_by(PaymentsUser.id.desc()) # type: ignore + payment_users = list(db_session.exec(statement).all()) # Convert to list + + return payment_users + +async def delete_payment_user( + request: Request, + org_id: int, + payment_user_id: int, + current_user: PublicUser | AnonymousUser | InternalUser, + db_session: Session, +) -> None: + # Check if organization exists + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "delete", db_session) + + # Get existing payment user + statement = select(PaymentsUser).where( + PaymentsUser.id == payment_user_id, + PaymentsUser.org_id == org_id + ) + payment_user = db_session.exec(statement).first() + if not payment_user: + raise HTTPException(status_code=404, detail="Payment user not found") + + # Delete payment user + db_session.delete(payment_user) + db_session.commit() + + +async def get_owned_courses( + request: Request, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> list[CourseRead]: + # Anonymous users don't own any courses + if isinstance(current_user, AnonymousUser): + return [] + + # Get all active/completed payment users for the current user + statement = select(PaymentsUser).where( + PaymentsUser.user_id == current_user.id, + PaymentsUser.status.in_([PaymentStatusEnum.ACTIVE, PaymentStatusEnum.COMPLETED]) # type: ignore + ) + payment_users = db_session.exec(statement).all() + + # Get all product IDs from payment users + product_ids = [pu.payment_product_id for pu in payment_users] + + # Get all courses linked to these products + courses = [] + for product_id in product_ids: + # Get courses linked to this product through PaymentsCourse + statement = ( + select(Course) + .join(PaymentsCourse, Course.id == PaymentsCourse.course_id) # type: ignore + .where(PaymentsCourse.payment_product_id == product_id) + ) + product_courses = db_session.exec(statement).all() + courses.extend(product_courses) + + # Remove duplicates by converting to set and back to list + unique_courses = list({course.id: course for course in courses}.values()) + + # Get authors for each course and convert to CourseRead + course_reads = [] + for course in unique_courses: + # Get course authors + authors_statement = ( + select(User) + .join(ResourceAuthor) + .where(ResourceAuthor.resource_uuid == course.course_uuid) + ) + authors = db_session.exec(authors_statement).all() + + # Convert authors to UserRead + author_reads = [UserRead.model_validate(author) for author in authors] + + # Create CourseRead object + course_read = CourseRead(**course.model_dump(), authors=author_reads) + course_reads.append(course_read) + + return course_reads + diff --git a/apps/api/src/services/payments/utils/stripe_utils.py b/apps/api/src/services/payments/utils/stripe_utils.py new file mode 100644 index 00000000..11a30d5e --- /dev/null +++ b/apps/api/src/services/payments/utils/stripe_utils.py @@ -0,0 +1,59 @@ +from fastapi import HTTPException +from sqlmodel import Session, select +import stripe +import logging + +from src.db.payments.payments_products import PaymentsProduct +from src.db.users import User +from src.db.payments.payments import PaymentsConfig + +logger = logging.getLogger(__name__) + +async def get_user_from_customer(customer_id: str, db_session: Session) -> User: + """Helper function to get user from Stripe customer ID""" + try: + customer = stripe.Customer.retrieve(customer_id) + statement = select(User).where(User.email == customer.email) + user = db_session.exec(statement).first() + if not user: + raise HTTPException( + status_code=404, detail=f"User not found for customer {customer_id}" + ) + return user + except stripe.StripeError as e: + logger.error(f"Stripe error retrieving customer {customer_id}: {str(e)}") + raise HTTPException( + status_code=400, detail="Error retrieving customer information" + ) + + +async def get_product_from_stripe_id( + product_id: str, db_session: Session +) -> PaymentsProduct: + """Helper function to get product from Stripe product ID""" + statement = select(PaymentsProduct).where( + PaymentsProduct.provider_product_id == product_id + ) + product = db_session.exec(statement).first() + if not product: + raise HTTPException(status_code=404, detail=f"Product not found: {product_id}") + return product + + +async def get_org_id_from_stripe_account( + stripe_account_id: str, + db_session: Session, +) -> int: + """Get organization ID from Stripe account ID""" + statement = select(PaymentsConfig).where( + PaymentsConfig.provider_specific_id == stripe_account_id + ) + config = db_session.exec(statement).first() + + if not config: + raise HTTPException( + status_code=404, + detail=f"No organization found for Stripe account {stripe_account_id}", + ) + + return config.org_id diff --git a/apps/api/src/services/payments/webhooks/payments_webhooks.py b/apps/api/src/services/payments/webhooks/payments_webhooks.py new file mode 100644 index 00000000..c357f77e --- /dev/null +++ b/apps/api/src/services/payments/webhooks/payments_webhooks.py @@ -0,0 +1,179 @@ +from typing import Literal +from fastapi import HTTPException, Request +from sqlmodel import Session, select +import stripe +import logging +from src.db.payments.payments_users import PaymentStatusEnum +from src.db.users import InternalUser +from src.services.payments.payments_users import update_payment_user_status +from src.services.payments.payments_stripe import get_stripe_internal_credentials +from src.db.payments.payments import PaymentsConfig, PaymentsConfigUpdate +from src.services.payments.payments_config import update_payments_config +from src.services.payments.utils.stripe_utils import get_org_id_from_stripe_account + +logger = logging.getLogger(__name__) + + +async def handle_stripe_webhook( + request: Request, + webhook_type: Literal["connect", "standard"], + db_session: Session, +) -> dict: + # Get Stripe credentials + creds = await get_stripe_internal_credentials() + webhook_secret = creds.get(f'stripe_webhook_{webhook_type}_secret') + stripe.api_key = creds.get("stripe_secret_key") + + if not webhook_secret: + logger.error("Stripe webhook secret not configured") + raise HTTPException(status_code=400, detail="Stripe webhook secret not configured") + + # Get request data + payload = await request.body() + sig_header = request.headers.get('stripe-signature') + + try: + # Verify webhook signature + event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret) + except ValueError: + logger.error(ValueError) + raise HTTPException(status_code=400, detail="Invalid payload") + except stripe.SignatureVerificationError: + logger.error(stripe.SignatureVerificationError) + raise HTTPException(status_code=400, detail="Invalid signature") + + try: + event_type = event.type + event_data = event.data.object + + # Get organization ID based on the event type + stripe_account_id = event.account + if not stripe_account_id: + logger.error("Stripe account ID not found") + raise HTTPException(status_code=400, detail="Stripe account ID not found") + + org_id = await get_org_id_from_stripe_account(stripe_account_id, db_session) + + # Handle internal account events + if event_type == 'account.application.authorized': + statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id) + config = db_session.exec(statement).first() + + if not config: + logger.error("No payments configuration found for this organization") + raise HTTPException( + status_code=404, + detail="No payments configuration found for this organization" + ) + + config_data = config.model_dump() + config_data.update({ + "enabled": True, + "active": True, + "provider_config": { + **config.provider_config, + "onboarding_completed": True + } + }) + await update_payments_config( + request, + org_id, + PaymentsConfigUpdate(**config_data), + InternalUser(), + db_session, + ) + + logger.info(f"Account authorized for organization {org_id}") + return {"status": "success", "message": "Account authorized successfully"} + + elif event_type == 'account.application.deauthorized': + statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id) + config = db_session.exec(statement).first() + + if not config: + raise HTTPException( + status_code=404, + detail="No payments configuration found for this organization" + ) + + config_data = config.model_dump() + config_data.update({ + "enabled": True, + "active": False, + "provider_config": { + **config.provider_config, + "onboarding_completed": False + } + }) + await update_payments_config( + request, + org_id, + PaymentsConfigUpdate(**config_data), + InternalUser(), + db_session, + ) + + logger.info(f"Account deauthorized for organization {org_id}") + return {"status": "success", "message": "Account deauthorized successfully"} + + # Handle payment-related events + elif event_type == "checkout.session.completed": + session = event_data + payment_user_id = int(session.get("metadata", {}).get("payment_user_id")) + + if session.get("mode") == "subscription": + if session.get("subscription"): + await update_payment_user_status( + request=request, + org_id=org_id, + payment_user_id=payment_user_id, + status=PaymentStatusEnum.ACTIVE, + current_user=InternalUser(), + db_session=db_session, + ) + else: + if session.get("payment_status") == "paid": + await update_payment_user_status( + request=request, + org_id=org_id, + payment_user_id=payment_user_id, + status=PaymentStatusEnum.COMPLETED, + current_user=InternalUser(), + db_session=db_session, + ) + + elif event_type == "customer.subscription.deleted": + subscription = event_data + payment_user_id = int(subscription.get("metadata", {}).get("payment_user_id")) + + await update_payment_user_status( + request=request, + org_id=org_id, + payment_user_id=payment_user_id, + status=PaymentStatusEnum.CANCELLED, + current_user=InternalUser(), + db_session=db_session, + ) + + elif event_type == "payment_intent.payment_failed": + payment_intent = event_data + payment_user_id = int(payment_intent.get("metadata", {}).get("payment_user_id")) + + await update_payment_user_status( + request=request, + org_id=org_id, + payment_user_id=payment_user_id, + status=PaymentStatusEnum.FAILED, + current_user=InternalUser(), + db_session=db_session, + ) + + else: + logger.warning(f"Unhandled event type: {event_type}") + return {"status": "ignored", "message": f"Unhandled event type: {event_type}"} + + return {"status": "success"} + + except Exception as e: + logger.error(f"Error processing webhook: {str(e)}") + raise HTTPException(status_code=400, detail=f"Error processing webhook: {str(e)}") \ No newline at end of file diff --git a/apps/api/src/services/roles/roles.py b/apps/api/src/services/roles/roles.py index c5939c2c..ea5d0715 100644 --- a/apps/api/src/services/roles/roles.py +++ b/apps/api/src/services/roles/roles.py @@ -2,7 +2,7 @@ from typing import Literal from uuid import uuid4 from sqlmodel import Session, select from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship_and_usergroups, + authorization_verify_based_on_roles_and_authorship, authorization_verify_if_user_is_anon, ) from src.db.users import AnonymousUser, PublicUser @@ -133,7 +133,7 @@ async def rbac_check( ): await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, role_uuid, db_session ) diff --git a/apps/api/src/services/users/usergroups.py b/apps/api/src/services/users/usergroups.py index f443b6b2..acc68ba3 100644 --- a/apps/api/src/services/users/usergroups.py +++ b/apps/api/src/services/users/usergroups.py @@ -9,7 +9,7 @@ from src.security.features_utils.usage import ( increase_feature_usage, ) from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship_and_usergroups, + authorization_verify_based_on_roles_and_authorship, authorization_verify_if_user_is_anon, ) from src.db.usergroup_resources import UserGroupResource @@ -492,7 +492,7 @@ async def rbac_check( ): await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, diff --git a/apps/api/src/services/users/users.py b/apps/api/src/services/users/users.py index c7277122..401f48ab 100644 --- a/apps/api/src/services/users/users.py +++ b/apps/api/src/services/users/users.py @@ -15,7 +15,7 @@ from src.services.orgs.invites import get_invite_code from src.services.users.avatars import upload_avatar from src.db.roles import Role, RoleRead from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship_and_usergroups, + authorization_verify_based_on_roles_and_authorship, authorization_verify_if_user_is_anon, ) from src.db.organizations import Organization, OrganizationRead @@ -491,7 +491,7 @@ async def authorize_user_action( # RBAC check authorized = ( - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, resource_uuid, db_session ) ) @@ -564,7 +564,7 @@ async def rbac_check( if current_user.id == 0: # if user is anonymous return True else: - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, "create", "user_x", db_session ) @@ -575,7 +575,7 @@ async def rbac_check( if current_user.user_uuid == user_uuid: return True - await authorization_verify_based_on_roles_and_authorship_and_usergroups( + await authorization_verify_based_on_roles_and_authorship( request, current_user.id, action, user_uuid, db_session ) diff --git a/apps/api/src/tests/utils/init_data_for_tests.py b/apps/api/src/tests/utils/init_data_for_tests.py index 8229cfe2..7e140d2b 100644 --- a/apps/api/src/tests/utils/init_data_for_tests.py +++ b/apps/api/src/tests/utils/init_data_for_tests.py @@ -8,7 +8,7 @@ from src.services.install.install import ( install_default_elements, ) - +# TODO: Depreceated and need to be removed and remade async def create_initial_data_for_tests(db_session: Session): # Install default elements await install_default_elements({}, db_session) diff --git a/apps/web/app/api/health/route.ts b/apps/web/app/api/health/route.ts new file mode 100644 index 00000000..182501ae --- /dev/null +++ b/apps/web/app/api/health/route.ts @@ -0,0 +1,39 @@ +export const dynamic = 'force-dynamic' // defaults to auto +export const revalidate = 0 + +import { NextResponse } from 'next/server'; +import { checkHealth } from '@services/utils/health'; + +export async function GET() { + const health = await checkHealth() + if (health.success === true) { + return NextResponse.json( + { + status: 'healthy', + timestamp: new Date().toISOString(), + health: health.data, + }, + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + } else { + return NextResponse.json( + { + status: 'unhealthy', + timestamp: new Date().toISOString(), + health: null, + error: health.HTTPmessage, + }, + { + status: 503, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + } +} diff --git a/apps/web/app/auth/forgot/forgot.tsx b/apps/web/app/auth/forgot/forgot.tsx index ecfce06e..c37af352 100644 --- a/apps/web/app/auth/forgot/forgot.tsx +++ b/apps/web/app/auth/forgot/forgot.tsx @@ -6,7 +6,7 @@ import FormLayout, { FormField, FormLabelAndMessage, Input, -} from '@components/StyledElements/Form/Form' +} from '@components/Objects/StyledElements/Form/Form' import * as Form from '@radix-ui/react-form' import { getOrgLogoMediaDirectory } from '@services/media/media' import { AlertTriangle, Info } from 'lucide-react' diff --git a/apps/web/app/auth/layout.tsx b/apps/web/app/auth/layout.tsx index 37fbd3a3..95dbe69b 100644 --- a/apps/web/app/auth/layout.tsx +++ b/apps/web/app/auth/layout.tsx @@ -1,6 +1,6 @@ 'use client' import { OrgProvider } from '@components/Contexts/OrgContext' -import ErrorUI from '@components/StyledElements/Error/Error' +import ErrorUI from '@components/Objects/StyledElements/Error/Error' import { useSearchParams } from 'next/navigation' diff --git a/apps/web/app/auth/login/login.tsx b/apps/web/app/auth/login/login.tsx index 6420396e..24fec8ea 100644 --- a/apps/web/app/auth/login/login.tsx +++ b/apps/web/app/auth/login/login.tsx @@ -4,7 +4,7 @@ import FormLayout, { FormField, FormLabelAndMessage, Input, -} from '@components/StyledElements/Form/Form' +} from '@components/Objects/StyledElements/Form/Form' import Image from 'next/image' import * as Form from '@radix-ui/react-form' import { useFormik } from 'formik' diff --git a/apps/web/app/auth/reset/reset.tsx b/apps/web/app/auth/reset/reset.tsx index c275f98f..c9d16bb9 100644 --- a/apps/web/app/auth/reset/reset.tsx +++ b/apps/web/app/auth/reset/reset.tsx @@ -6,7 +6,7 @@ import FormLayout, { FormField, FormLabelAndMessage, Input, -} from '@components/StyledElements/Form/Form' +} from '@components/Objects/StyledElements/Form/Form' import * as Form from '@radix-ui/react-form' import { getOrgLogoMediaDirectory } from '@services/media/media' import { AlertTriangle, Info } from 'lucide-react' diff --git a/apps/web/app/auth/signup/InviteOnlySignUp.tsx b/apps/web/app/auth/signup/InviteOnlySignUp.tsx index 0ffa7952..41a21618 100644 --- a/apps/web/app/auth/signup/InviteOnlySignUp.tsx +++ b/apps/web/app/auth/signup/InviteOnlySignUp.tsx @@ -7,7 +7,7 @@ import FormLayout, { FormLabelAndMessage, Input, Textarea, -} from '@components/StyledElements/Form/Form' +} from '@components/Objects/StyledElements/Form/Form' import * as Form from '@radix-ui/react-form' import { AlertTriangle, Check, User } from 'lucide-react' import Link from 'next/link' @@ -110,8 +110,10 @@ function InviteOnlySignUpComponent(props: InviteOnlySignUpProps) {
{message}

- -
Login
+ +
Login to your account
)} diff --git a/apps/web/app/auth/signup/OpenSignup.tsx b/apps/web/app/auth/signup/OpenSignup.tsx index 36a2e821..20fe9c91 100644 --- a/apps/web/app/auth/signup/OpenSignup.tsx +++ b/apps/web/app/auth/signup/OpenSignup.tsx @@ -7,7 +7,7 @@ import FormLayout, { FormLabelAndMessage, Input, Textarea, -} from '@components/StyledElements/Form/Form' +} from '@components/Objects/StyledElements/Form/Form' import * as Form from '@radix-ui/react-form' import { AlertTriangle, Check, User } from 'lucide-react' import Link from 'next/link' diff --git a/apps/web/app/auth/signup/signup.tsx b/apps/web/app/auth/signup/signup.tsx index 1ed093c9..71e977e1 100644 --- a/apps/web/app/auth/signup/signup.tsx +++ b/apps/web/app/auth/signup/signup.tsx @@ -14,7 +14,7 @@ import InviteOnlySignUpComponent from './InviteOnlySignUp' import { useRouter, useSearchParams } from 'next/navigation' import { validateInviteCode } from '@services/organizations/invites' import PageLoading from '@components/Objects/Loaders/PageLoading' -import Toast from '@components/StyledElements/Toast/Toast' +import Toast from '@components/Objects/StyledElements/Toast/Toast' import toast from 'react-hot-toast' import { BarLoader } from 'react-spinners' import { joinOrg } from '@services/organizations/orgs' diff --git a/apps/web/app/install/install.tsx b/apps/web/app/install/install.tsx index d5753dd9..f2d7b1aa 100644 --- a/apps/web/app/install/install.tsx +++ b/apps/web/app/install/install.tsx @@ -1,7 +1,7 @@ 'use client' import React, { useEffect } from 'react' import { INSTALL_STEPS } from './steps/steps' -import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper' +import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' import { useRouter, useSearchParams } from 'next/navigation' import { Suspense } from 'react' diff --git a/apps/web/app/install/steps/account_creation.tsx b/apps/web/app/install/steps/account_creation.tsx index 1a8d8531..492b492c 100644 --- a/apps/web/app/install/steps/account_creation.tsx +++ b/apps/web/app/install/steps/account_creation.tsx @@ -4,7 +4,7 @@ import FormLayout, { FormField, FormLabelAndMessage, Input, -} from '@components/StyledElements/Form/Form' +} from '@components/Objects/StyledElements/Form/Form' import * as Form from '@radix-ui/react-form' import { getAPIUrl } from '@services/config/config' import { createNewUserInstall, updateInstall } from '@services/install/install' diff --git a/apps/web/app/install/steps/org_creation.tsx b/apps/web/app/install/steps/org_creation.tsx index 5efd2c93..66e5fd6b 100644 --- a/apps/web/app/install/steps/org_creation.tsx +++ b/apps/web/app/install/steps/org_creation.tsx @@ -3,7 +3,7 @@ import FormLayout, { FormField, FormLabelAndMessage, Input, -} from '@components/StyledElements/Form/Form' +} from '@components/Objects/StyledElements/Form/Form' import * as Form from '@radix-ui/react-form' import { useFormik } from 'formik' import { BarLoader } from 'react-spinners' diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/collection/[collectionid]/error.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/collection/[collectionid]/error.tsx index d5320406..058142f5 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/collection/[collectionid]/error.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/collection/[collectionid]/error.tsx @@ -1,6 +1,6 @@ 'use client' // Error components must be Client Components -import ErrorUI from '@components/StyledElements/Error/Error' +import ErrorUI from '@components/Objects/StyledElements/Error/Error' import { useEffect } from 'react' export default function Error({ diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/collection/[collectionid]/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/collection/[collectionid]/page.tsx index 523ca9bd..aebb4872 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/collection/[collectionid]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/collection/[collectionid]/page.tsx @@ -1,4 +1,4 @@ -import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper' +import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' import { getUriWithOrg } from '@services/config/config' import { getCollectionById } from '@services/courses/collections' import { getCourseThumbnailMediaDirectory } from '@services/media/media' diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx index 343bfacf..dfa4b147 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx @@ -1,17 +1,17 @@ import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement' -import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle' -import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper' +import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle' +import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' import { getUriWithOrg } from '@services/config/config' import { getOrganizationContextInfo } from '@services/organizations/orgs' import { Metadata } from 'next' import Link from 'next/link' import CollectionThumbnail from '@components/Objects/Thumbnails/CollectionThumbnail' -import NewCollectionButton from '@components/StyledElements/Buttons/NewCollectionButton' -import ContentPlaceHolderIfUserIsNotAdmin from '@components/ContentPlaceHolder' +import NewCollectionButton from '@components/Objects/StyledElements/Buttons/NewCollectionButton' import { nextAuthOptions } from 'app/auth/options' import { getServerSession } from 'next-auth' import { getOrgCollections } from '@services/courses/collections' import { getOrgThumbnailMediaDirectory } from '@services/media/media' +import ContentPlaceHolderIfUserIsNotAdmin from '@components/Objects/ContentPlaceHolder' type MetadataProps = { params: { orgslug: string; courseid: string } diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx index 3dc1853b..20d03e54 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx @@ -7,7 +7,7 @@ import { BookOpenCheck, Check, CheckCircle, MoreVertical, UserRoundPen } from 'l import { markActivityAsComplete } from '@services/courses/activity' import DocumentPdfActivity from '@components/Objects/Activities/DocumentPdf/DocumentPdf' import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators' -import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper' +import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' import { usePathname, useRouter } from 'next/navigation' import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement' import { getCourseThumbnailMediaDirectory } from '@services/media/media' @@ -24,8 +24,9 @@ import { AssignmentsTaskProvider } from '@components/Contexts/Assignments/Assign import AssignmentSubmissionProvider, { useAssignmentSubmission } from '@components/Contexts/Assignments/AssignmentSubmissionContext' import toast from 'react-hot-toast' import { mutate } from 'swr' -import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal' +import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' import { useMediaQuery } from 'usehooks-ts' +import PaidCourseActivityDisclaimer from '@components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer' interface ActivityClientProps { activityid: string @@ -129,7 +130,7 @@ function ActivityClient(props: ActivityClientProps) {
- {activity && activity.published == true && ( + {activity && activity.published == true && activity.content.paid_access != false && ( {activity.activity_type != 'TYPE_ASSIGNMENT' && <> @@ -173,40 +174,44 @@ function ActivityClient(props: ActivityClientProps) { )} {activity && activity.published == true && ( -
-
- {activity.activity_type == 'TYPE_DYNAMIC' && ( - - )} - {/* todo : use apis & streams instead of this */} - {activity.activity_type == 'TYPE_VIDEO' && ( - - )} - {activity.activity_type == 'TYPE_DOCUMENT' && ( - - )} - {activity.activity_type == 'TYPE_ASSIGNMENT' && ( + <> + {activity.content.paid_access == false ? ( + + ) : ( +
+ {/* Activity Types */}
- {assignment ? ( - - - - - - - - ) : ( -
+ {activity.activity_type == 'TYPE_DYNAMIC' && ( + + )} + {activity.activity_type == 'TYPE_VIDEO' && ( + + )} + {activity.activity_type == 'TYPE_DOCUMENT' && ( + + )} + {activity.activity_type == 'TYPE_ASSIGNMENT' && ( +
+ {assignment ? ( + + + + + + + + ) : ( +
+ )} +
)}
- )} -
-
+
+ )} + )} {
}
diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/error.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/error.tsx index d5320406..058142f5 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/error.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/error.tsx @@ -1,6 +1,6 @@ 'use client' // Error components must be Client Components -import ErrorUI from '@components/StyledElements/Error/Error' +import ErrorUI from '@components/Objects/StyledElements/Error/Error' import { useEffect } from 'react' export default function Error({ diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx index b3311d8c..b98bb364 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx @@ -1,5 +1,4 @@ 'use client' -import { removeCourse, startCourse } from '@services/courses/activity' import Link from 'next/link' import React, { useEffect, useState } from 'react' import { getUriWithOrg } from '@services/config/config' @@ -7,7 +6,7 @@ import PageLoading from '@components/Objects/Loaders/PageLoading' import { revalidateTags } from '@services/utils/ts/requests' import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators' import { useRouter } from 'next/navigation' -import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper' +import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' import { getCourseThumbnailMediaDirectory, getUserAvatarMediaDirectory, @@ -15,15 +14,13 @@ import { import { ArrowRight, Backpack, Check, File, Sparkles, Video } from 'lucide-react' import { useOrg } from '@components/Contexts/OrgContext' import UserAvatar from '@components/Objects/UserAvatar' -import CourseUpdates from '@components/Objects/CourseUpdates/CourseUpdates' +import CourseUpdates from '@components/Objects/Courses/CourseUpdates/CourseUpdates' import { CourseProvider } from '@components/Contexts/CourseContext' -import { useLHSession } from '@components/Contexts/LHSessionContext' import { useMediaQuery } from 'usehooks-ts' +import CoursesActions from '@components/Objects/Courses/CourseActions/CoursesActions' const CourseClient = (props: any) => { - const [user, setUser] = useState({}) const [learnings, setLearnings] = useState([]) - const session = useLHSession() as any; const courseuuid = props.courseuuid const orgslug = props.orgslug const course = props.course @@ -37,33 +34,6 @@ const CourseClient = (props: any) => { setLearnings(learnings) } - async function startCourseUI() { - // Create activity - await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token) - await revalidateTags(['courses'], orgslug) - router.refresh() - - // refresh page (FIX for Next.js BUG) - // window.location.reload(); - } - - function isCourseStarted() { - const runs = course.trail?.runs - if (!runs) return false - return runs.some( - (run: any) => - run.status === 'STATUS_IN_PROGRESS' && run.course_id === course.id - ) - } - - async function quitCourse() { - // Close activity - let activity = await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token) - // Mutate course - await revalidateTags(['courses'], orgslug) - router.refresh() - } - useEffect(() => { getLearningTags() }, [org, course]) @@ -80,7 +50,7 @@ const CourseClient = (props: any) => {

{course.name}

- {!isMobile && + {!isMobile && }
@@ -113,11 +83,11 @@ const CourseClient = (props: any) => { course={course} /> -
-
-

Description

+
+
+

About

-

{course.description}

+

{course.about}

{learnings.length > 0 && learnings[0] !== 'null' && ( @@ -187,7 +157,7 @@ const CourseClient = (props: any) => { />
)} - {activity.activity_type === + {activity.activity_type === 'TYPE_ASSIGNMENT' && (
{ )} - {activity.activity_type === + {activity.activity_type === 'TYPE_ASSIGNMENT' && ( <> { })}
-
- {user && ( -
- -
-
- Author -
-
- {course.authors[0].first_name && - course.authors[0].last_name && ( -
-

- {course.authors[0].first_name + - ' ' + - course.authors[0].last_name} -

- - {' '} - @{course.authors[0].username} - -
- )} - {!course.authors[0].first_name && - !course.authors[0].last_name && ( -
-

@{course.authors[0].username}

-
- )} -
-
-
- )} - - {isCourseStarted() ? ( - - ) : ( - - )} +
+
diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/courses/courses.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/courses/courses.tsx index a8a28136..bb17ecd6 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/courses/courses.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/courses/courses.tsx @@ -1,13 +1,13 @@ 'use client' import CreateCourseModal from '@components/Objects/Modals/Course/Create/CreateCourse' -import Modal from '@components/StyledElements/Modal/Modal' +import Modal from '@components/Objects/StyledElements/Modal/Modal' import React from 'react' import { useSearchParams } from 'next/navigation' -import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper' -import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle' +import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' +import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle' import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement' import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail' -import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton' +import NewCourseButton from '@components/Objects/StyledElements/Buttons/NewCourseButton' import useAdminStatus from '@components/Hooks/useAdminStatus' interface CourseProps { diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/courses/error.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/courses/error.tsx index d5320406..058142f5 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/courses/error.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/courses/error.tsx @@ -1,6 +1,6 @@ 'use client' // Error components must be Client Components -import ErrorUI from '@components/StyledElements/Error/Error' +import ErrorUI from '@components/Objects/StyledElements/Error/Error' import { useEffect } from 'react' export default function Error({ diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/error.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/error.tsx index d5320406..058142f5 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/error.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/error.tsx @@ -1,6 +1,6 @@ 'use client' // Error components must be Client Components -import ErrorUI from '@components/StyledElements/Error/Error' +import ErrorUI from '@components/Objects/StyledElements/Error/Error' import { useEffect } from 'react' export default function Error({ diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/layout.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/layout.tsx index 96e5c1aa..a825e3a5 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/layout.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/layout.tsx @@ -1,8 +1,8 @@ 'use client' import '@styles/globals.css' -import { Menu } from '@components/Objects/Menu/Menu' import { SessionProvider } from 'next-auth/react' -import Watermark from '@components/Watermark' +import Watermark from '@components/Objects/Watermark' +import { OrgMenu } from '@components/Objects/Menus/OrgMenu' export default function RootLayout({ children, @@ -14,7 +14,7 @@ export default function RootLayout({ return ( <> - + {children} diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/page.tsx index 004ce80b..294614ce 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/page.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/page.tsx @@ -4,14 +4,14 @@ import { getUriWithOrg } from '@services/config/config' import { getOrgCourses } from '@services/courses/courses' import Link from 'next/link' import { getOrganizationContextInfo } from '@services/organizations/orgs' -import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper' -import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle' +import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' +import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle' import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail' import CollectionThumbnail from '@components/Objects/Thumbnails/CollectionThumbnail' import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement' -import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton' -import NewCollectionButton from '@components/StyledElements/Buttons/NewCollectionButton' -import ContentPlaceHolderIfUserIsNotAdmin from '@components/ContentPlaceHolder' +import NewCourseButton from '@components/Objects/StyledElements/Buttons/NewCourseButton' +import NewCollectionButton from '@components/Objects/StyledElements/Buttons/NewCollectionButton' +import ContentPlaceHolderIfUserIsNotAdmin from '@components/Objects/ContentPlaceHolder' import { getOrgCollections } from '@services/courses/collections' import { getServerSession } from 'next-auth' import { nextAuthOptions } from 'app/auth/options' diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/trail/trail.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/trail/trail.tsx index 2d5a44c7..dbf3bb33 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/trail/trail.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/trail/trail.tsx @@ -3,8 +3,8 @@ import { useLHSession } from '@components/Contexts/LHSessionContext' import { useOrg } from '@components/Contexts/OrgContext' import PageLoading from '@components/Objects/Loaders/PageLoading' import TrailCourseElement from '@components/Pages/Trail/TrailCourseElement' -import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle' -import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper' +import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle' +import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' import { getAPIUrl } from '@services/config/config' import { swrFetcher } from '@services/utils/ts/requests' import React, { useEffect } from 'react' diff --git a/apps/web/app/orgs/[orgslug]/dash/ClientAdminLayout.tsx b/apps/web/app/orgs/[orgslug]/dash/ClientAdminLayout.tsx index 48370ca3..a65f6c02 100644 --- a/apps/web/app/orgs/[orgslug]/dash/ClientAdminLayout.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/ClientAdminLayout.tsx @@ -1,9 +1,9 @@ 'use client'; -import DashLeftMenu from '@components/Dashboard/UI/DashLeftMenu' -import DashMobileMenu from '@components/Dashboard/UI/DashMobileMenu' +import DashLeftMenu from '@components/Dashboard/Menus/DashLeftMenu'; +import DashMobileMenu from '@components/Dashboard/Menus/DashMobileMenu'; import AdminAuthorization from '@components/Security/AdminAuthorization' import { SessionProvider } from 'next-auth/react' -import React, { useState, useEffect } from 'react' +import React from 'react' import { useMediaQuery } from 'usehooks-ts'; function ClientAdminLayout({ diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor/Subs/AssignmentTaskGeneralEdit.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor/Subs/AssignmentTaskGeneralEdit.tsx index f7bae0ef..f346e469 100644 --- a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor/Subs/AssignmentTaskGeneralEdit.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor/Subs/AssignmentTaskGeneralEdit.tsx @@ -3,7 +3,7 @@ import { useAssignments } from '@components/Contexts/Assignments/AssignmentConte import { useAssignmentsTask, useAssignmentsTaskDispatch } from '@components/Contexts/Assignments/AssignmentsTaskContext'; import { useLHSession } from '@components/Contexts/LHSessionContext'; import { useOrg } from '@components/Contexts/OrgContext'; -import FormLayout, { FormField, FormLabelAndMessage, Input, Textarea } from '@components/StyledElements/Form/Form'; +import FormLayout, { FormField, FormLabelAndMessage, Input, Textarea } from '@components/Objects/StyledElements/Form/Form'; import * as Form from '@radix-ui/react-form'; import { getActivityByID } from '@services/courses/activities'; import { updateAssignmentTask, updateReferenceFile } from '@services/courses/assignments'; diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Tasks.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Tasks.tsx index 63997abe..5a816ab2 100644 --- a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Tasks.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Tasks.tsx @@ -1,5 +1,5 @@ import { useAssignments } from '@components/Contexts/Assignments/AssignmentContext' -import Modal from '@components/StyledElements/Modal/Modal'; +import Modal from '@components/Objects/StyledElements/Modal/Modal'; import { FileUp, ListTodo, PanelLeftOpen, Plus } from 'lucide-react'; import React, { useEffect } from 'react' import NewTaskModal from './Modals/NewTaskModal'; diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/page.tsx index 7c3144a5..fe6f4bdf 100644 --- a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/page.tsx @@ -1,9 +1,9 @@ 'use client'; -import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs' +import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs' import { BookOpen, BookX, EllipsisVertical, Eye, Layers2, Monitor, UserRoundPen } from 'lucide-react' import React, { useEffect } from 'react' import { AssignmentProvider, useAssignments } from '@components/Contexts/Assignments/AssignmentContext'; -import ToolTip from '@components/StyledElements/Tooltip/Tooltip'; +import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'; import { updateAssignment } from '@services/courses/assignments'; import { useLHSession } from '@components/Contexts/LHSessionContext'; import { mutate } from 'swr'; diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/subpages/AssignmentSubmissionsSubPage.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/subpages/AssignmentSubmissionsSubPage.tsx index 33a55715..4cc32f02 100644 --- a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/subpages/AssignmentSubmissionsSubPage.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/subpages/AssignmentSubmissionsSubPage.tsx @@ -1,6 +1,6 @@ import { useLHSession } from '@components/Contexts/LHSessionContext'; import UserAvatar from '@components/Objects/UserAvatar'; -import Modal from '@components/StyledElements/Modal/Modal'; +import Modal from '@components/Objects/StyledElements/Modal/Modal'; import { getAPIUrl } from '@services/config/config'; import { getUserAvatarMediaDirectory } from '@services/media/media'; import { swrFetcher } from '@services/utils/ts/requests'; diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/page.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/page.tsx index 8e11e60b..0ee94fd6 100644 --- a/apps/web/app/orgs/[orgslug]/dash/assignments/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useLHSession } from '@components/Contexts/LHSessionContext'; import { useOrg } from '@components/Contexts/OrgContext'; -import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs' +import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs' import { getAPIUrl, getUriWithOrg } from '@services/config/config'; import { getAssignmentsFromACourse } from '@services/courses/assignments'; import { getCourseThumbnailMediaDirectory } from '@services/media/media'; diff --git a/apps/web/app/orgs/[orgslug]/dash/courses/client.tsx b/apps/web/app/orgs/[orgslug]/dash/courses/client.tsx index 57f1ba2a..766c978b 100644 --- a/apps/web/app/orgs/[orgslug]/dash/courses/client.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/courses/client.tsx @@ -1,10 +1,10 @@ 'use client' -import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs' +import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs' import CreateCourseModal from '@components/Objects/Modals/Course/Create/CreateCourse' import CourseThumbnail, { removeCoursePrefix } from '@components/Objects/Thumbnails/CourseThumbnail' import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement' -import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton' -import Modal from '@components/StyledElements/Modal/Modal' +import NewCourseButton from '@components/Objects/StyledElements/Buttons/NewCourseButton' +import Modal from '@components/Objects/StyledElements/Modal/Modal' import { useSearchParams } from 'next/navigation' import React from 'react' import useAdminStatus from '@components/Hooks/useAdminStatus' diff --git a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx index 9b44a988..0c37a366 100644 --- a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx @@ -1,14 +1,14 @@ 'use client' -import EditCourseStructure from '../../../../../../../../components/Dashboard/Course/EditCourseStructure/EditCourseStructure' import { getUriWithOrg } from '@services/config/config' import React from 'react' import { CourseProvider } from '../../../../../../../../components/Contexts/CourseContext' import Link from 'next/link' -import { CourseOverviewTop } from '@components/Dashboard/UI/CourseOverviewTop' +import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop' import { motion } from 'framer-motion' -import EditCourseGeneral from '@components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral' import { GalleryVerticalEnd, Info, UserRoundCog } from 'lucide-react' -import EditCourseAccess from '@components/Dashboard/Course/EditCourseAccess/EditCourseAccess' +import EditCourseStructure from '@components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure' +import EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral' +import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess' export type CourseOverviewParams = { orgslug: string diff --git a/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx index d748809b..6054523b 100644 --- a/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx @@ -1,11 +1,11 @@ 'use client' -import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs' +import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs' import { getUriWithOrg } from '@services/config/config' import { Info } from 'lucide-react' import Link from 'next/link' import React, { useEffect } from 'react' import { motion } from 'framer-motion' -import OrgEditGeneral from '@components/Dashboard/Org/OrgEditGeneral/OrgEditGeneral' +import OrgEditGeneral from '@components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral' export type OrgParams = { subpage: string diff --git a/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx new file mode 100644 index 00000000..173ececd --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx @@ -0,0 +1,142 @@ +'use client' +import React, { useState, useEffect } from 'react' +import { motion } from 'framer-motion' +import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs' +import Link from 'next/link' +import { getUriWithOrg } from '@services/config/config' +import { CreditCard, Settings, Repeat, BookOpen, Users, DollarSign, Gem } from 'lucide-react' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import { useOrg } from '@components/Contexts/OrgContext' +import PaymentsConfigurationPage from '@components/Dashboard/Pages/Payments/PaymentsConfigurationPage' +import PaymentsProductPage from '@components/Dashboard/Pages/Payments/PaymentsProductPage' +import PaymentsCustomersPage from '@components/Dashboard/Pages/Payments/PaymentsCustomersPage' +import useFeatureFlag from '@components/Hooks/useFeatureFlag' + +export type PaymentsParams = { + subpage: string + orgslug: string +} + +function PaymentsPage({ params }: { params: PaymentsParams }) { + const session = useLHSession() as any + const org = useOrg() as any + const [selectedSubPage, setSelectedSubPage] = useState(params.subpage || 'general') + const [H1Label, setH1Label] = useState('') + const [H2Label, setH2Label] = useState('') + + const isPaymentsEnabled = useFeatureFlag({ + path: ['features', 'payments', 'enabled'], + defaultValue: false + }) + + useEffect(() => { + handleLabels() + }, [selectedSubPage]) + + if (!isPaymentsEnabled) { + return ( +
+
+

Payments Not Available

+

The payments feature is not enabled for this organization.

+

Please contact your administrator to enable payments.

+
+
+ ) + } + + function handleLabels() { + if (selectedSubPage === 'general') { + setH1Label('Payments') + setH2Label('Overview of your payment settings and transactions') + } + if (selectedSubPage === 'configuration') { + setH1Label('Payment Configuration') + setH2Label('Set up and manage your payment gateway') + } + if (selectedSubPage === 'subscriptions') { + setH1Label('Subscriptions') + setH2Label('Manage your subscription plans') + } + if (selectedSubPage === 'paid-products') { + setH1Label('Paid Products') + setH2Label('Manage your paid products and pricing') + } + if (selectedSubPage === 'customers') { + setH1Label('Customers') + setH2Label('View and manage your customer information') + } + } + + return ( +
+
+ +
+
+
+ {H1Label} +
+
+ {H2Label}{' '} +
+
+
+
+ } + label="Customers" + isActive={selectedSubPage === 'customers'} + onClick={() => setSelectedSubPage('customers')} + /> + } + label="Products & Subscriptions" + isActive={selectedSubPage === 'paid-products'} + onClick={() => setSelectedSubPage('paid-products')} + /> + } + label="Configuration" + isActive={selectedSubPage === 'configuration'} + onClick={() => setSelectedSubPage('configuration')} + /> + +
+
+
+ + {selectedSubPage === 'general' &&
General
} + {selectedSubPage === 'configuration' && } + {selectedSubPage === 'paid-products' && } + {selectedSubPage === 'customers' && } +
+
+ ) +} + +const TabLink = ({ href, icon, label, isActive, onClick }: { href: string, icon: React.ReactNode, label: string, isActive: boolean, onClick: () => void }) => ( + +
+
+ {icon} +
{label}
+
+
+ +) + +export default PaymentsPage diff --git a/apps/web/app/orgs/[orgslug]/dash/user-account/owned/page.tsx b/apps/web/app/orgs/[orgslug]/dash/user-account/owned/page.tsx new file mode 100644 index 00000000..767db3cf --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/dash/user-account/owned/page.tsx @@ -0,0 +1,64 @@ +'use client' + +import React from 'react' +import { useOrg } from '@components/Contexts/OrgContext' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import useSWR from 'swr' +import { getOwnedCourses } from '@services/payments/payments' +import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail' +import PageLoading from '@components/Objects/Loaders/PageLoading' +import { BookOpen, Package2 } from 'lucide-react' + +function OwnedCoursesPage() { + const org = useOrg() as any + const session = useLHSession() as any + const access_token = session?.data?.tokens?.access_token + + const { data: ownedCourses, error, isLoading } = useSWR( + org ? [`/payments/${org.id}/courses/owned`, access_token] : null, + ([url, token]) => getOwnedCourses(org.id, token) + ) + + if (isLoading) return + if (error) return
Error loading owned courses
+ + return ( +
+
+
+ +
+

My Courses

+

Courses you have purchased or subscribed to

+
+
+
+ +
+ {ownedCourses?.map((course: any) => ( +
+ +
+ ))} + + {(!ownedCourses || ownedCourses.length === 0) && ( +
+
+
+ +
+

+ No purchased courses +

+

+ You haven't purchased any courses yet +

+
+
+ )} +
+
+ ) +} + +export default OwnedCoursesPage diff --git a/apps/web/app/orgs/[orgslug]/dash/user-account/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/user-account/settings/[subpage]/page.tsx index 6162f70e..357bbd39 100644 --- a/apps/web/app/orgs/[orgslug]/dash/user-account/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/user-account/settings/[subpage]/page.tsx @@ -1,12 +1,12 @@ 'use client' import React, { useEffect } from 'react' import { motion } from 'framer-motion' -import UserEditGeneral from '@components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral' -import UserEditPassword from '@components/Dashboard/UserAccount/UserEditPassword/UserEditPassword' +import UserEditGeneral from '@components/Dashboard/Pages/UserAccount/UserEditGeneral/UserEditGeneral' +import UserEditPassword from '@components/Dashboard/Pages/UserAccount/UserEditPassword/UserEditPassword' import Link from 'next/link' import { getUriWithOrg } from '@services/config/config' import { Info, Lock } from 'lucide-react' -import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs' +import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs' import { useLHSession } from '@components/Contexts/LHSessionContext' export type SettingsParams = { diff --git a/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx index d6342e38..30e74c0a 100644 --- a/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx @@ -5,13 +5,13 @@ import Link from 'next/link' import { useMediaQuery } from 'usehooks-ts' import { getUriWithOrg } from '@services/config/config' import { Monitor, ScanEye, SquareUserRound, UserPlus, Users } from 'lucide-react' -import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs' +import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs' import { useLHSession } from '@components/Contexts/LHSessionContext' import { useOrg } from '@components/Contexts/OrgContext' -import OrgUsers from '@components/Dashboard/Users/OrgUsers/OrgUsers' -import OrgAccess from '@components/Dashboard/Users/OrgAccess/OrgAccess' -import OrgUsersAdd from '@components/Dashboard/Users/OrgUsersAdd/OrgUsersAdd' -import OrgUserGroups from '@components/Dashboard/Users/OrgUserGroups/OrgUserGroups' +import OrgUsers from '@components/Dashboard/Pages/Users/OrgUsers/OrgUsers' +import OrgAccess from '@components/Dashboard/Pages/Users/OrgAccess/OrgAccess' +import OrgUsersAdd from '@components/Dashboard/Pages/Users/OrgUsersAdd/OrgUsersAdd' +import OrgUserGroups from '@components/Dashboard/Pages/Users/OrgUserGroups/OrgUserGroups' export type SettingsParams = { subpage: string diff --git a/apps/web/app/orgs/[orgslug]/health/page.tsx b/apps/web/app/orgs/[orgslug]/health/page.tsx deleted file mode 100644 index e9d89171..00000000 --- a/apps/web/app/orgs/[orgslug]/health/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react' - -function HealthPage() { - return ( -
OK
- ) -} - -export default HealthPage \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/layout.tsx b/apps/web/app/orgs/[orgslug]/layout.tsx index 56735360..42912f32 100644 --- a/apps/web/app/orgs/[orgslug]/layout.tsx +++ b/apps/web/app/orgs/[orgslug]/layout.tsx @@ -1,9 +1,9 @@ 'use client' import { OrgProvider } from '@components/Contexts/OrgContext' import NextTopLoader from 'nextjs-toploader'; -import Toast from '@components/StyledElements/Toast/Toast' +import Toast from '@components/Objects/StyledElements/Toast/Toast' import '@styles/globals.css' -import Onboarding from '@components/Onboarding/Onboarding'; +import Onboarding from '@components/Objects/Onboarding/Onboarding'; export default function RootLayout({ children, diff --git a/apps/web/app/payments/stripe/connect/oauth/page.tsx b/apps/web/app/payments/stripe/connect/oauth/page.tsx new file mode 100644 index 00000000..03b068a7 --- /dev/null +++ b/apps/web/app/payments/stripe/connect/oauth/page.tsx @@ -0,0 +1,124 @@ +'use client' +import React, { useEffect, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import { useOrg } from '@components/Contexts/OrgContext' +import { getUriWithOrg } from '@services/config/config' +import { Check, Loader2, AlertTriangle } from 'lucide-react' +import { motion } from 'framer-motion' +import toast from 'react-hot-toast' +import { verifyStripeConnection } from '@services/payments/payments' +import Image from 'next/image' +import learnhouseIcon from 'public/learnhouse_bigicon_1.png' + +function StripeConnectCallback() { + const router = useRouter() + const searchParams = useSearchParams() + const session = useLHSession() as any + const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing') + const [message, setMessage] = useState('') + + useEffect(() => { + const verifyConnection = async () => { + try { + const code = searchParams.get('code') + const state = searchParams.get('state') + const orgId = state?.split('=')[1] // Extract org_id value after '=' + + if (!code || !orgId) { + throw new Error('Missing required parameters') + } + + const response = await verifyStripeConnection( + parseInt(orgId), + code, + session?.data?.tokens?.access_token + ) + + // Wait for 1 second to show processing state + await new Promise(resolve => setTimeout(resolve, 1000)) + + setStatus('success') + setMessage('Successfully connected to Stripe!') + + // Close the window after 2 seconds of showing success + setTimeout(() => { + window.close() + }, 2000) + + } catch (error) { + console.error('Error verifying Stripe connection:', error) + setStatus('error') + setMessage('Failed to complete Stripe connection') + toast.error('Failed to connect to Stripe') + } + } + + if (session) { + verifyConnection() + } + }, [session, router, searchParams]) + + return ( +
+
+
+ +
+ + +
+ {status === 'processing' && ( + <> + +

+ Completing Stripe Connection +

+

+ Please wait while we finish setting up your Stripe integration... +

+ + )} + + {status === 'success' && ( + <> +
+ +
+

{message}

+

+ You can now return to the dashboard to start using payments. +

+ + )} + + {status === 'error' && ( + <> +
+ +
+

{message}

+

+ Please try again or contact support if the problem persists. +

+ + )} +
+
+
+
+ ) +} + +export default StripeConnectCallback \ No newline at end of file diff --git a/apps/web/components/Contexts/LHSessionContext.tsx b/apps/web/components/Contexts/LHSessionContext.tsx index 90acb9d8..b7a2965d 100644 --- a/apps/web/components/Contexts/LHSessionContext.tsx +++ b/apps/web/components/Contexts/LHSessionContext.tsx @@ -1,17 +1,13 @@ 'use client' import PageLoading from '@components/Objects/Loaders/PageLoading'; import { useSession } from 'next-auth/react'; -import React, { useContext, createContext, useEffect } from 'react' +import React, { useContext, createContext } from 'react' export const SessionContext = createContext({}) as any function LHSessionProvider({ children }: { children: React.ReactNode }) { const session = useSession(); - useEffect(() => { - }, []) - - if (session && session.status == 'loading') { return } diff --git a/apps/web/components/Contexts/OrgContext.tsx b/apps/web/components/Contexts/OrgContext.tsx index 8b50e39a..60a76144 100644 --- a/apps/web/components/Contexts/OrgContext.tsx +++ b/apps/web/components/Contexts/OrgContext.tsx @@ -4,8 +4,8 @@ import { swrFetcher } from '@services/utils/ts/requests' import React, { createContext, useContext, useMemo } from 'react' import useSWR from 'swr' import { useLHSession } from '@components/Contexts/LHSessionContext' -import ErrorUI from '@components/StyledElements/Error/Error' -import InfoUI from '@components/StyledElements/Info/Info' +import ErrorUI from '@components/Objects/StyledElements/Error/Error' +import InfoUI from '@components/Objects/StyledElements/Info/Info' import { usePathname } from 'next/navigation' export const OrgContext = createContext(null) diff --git a/apps/web/components/Dashboard/UI/DashLeftMenu.tsx b/apps/web/components/Dashboard/Menus/DashLeftMenu.tsx similarity index 78% rename from apps/web/components/Dashboard/UI/DashLeftMenu.tsx rename to apps/web/components/Dashboard/Menus/DashLeftMenu.tsx index b280d76b..4b0a3931 100644 --- a/apps/web/components/Dashboard/UI/DashLeftMenu.tsx +++ b/apps/web/components/Dashboard/Menus/DashLeftMenu.tsx @@ -1,9 +1,9 @@ 'use client' import { useOrg } from '@components/Contexts/OrgContext' import { signOut } from 'next-auth/react' -import ToolTip from '@components/StyledElements/Tooltip/Tooltip' +import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' import LearnHouseDashboardLogo from '@public/dashLogo.png' -import { Backpack, BookCopy, Home, LogOut, School, Settings, Users } from 'lucide-react' +import { Backpack, BadgeDollarSign, BookCopy, Home, LogOut, Package2, School, Settings, Users, Vault } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import React, { useEffect } from 'react' @@ -11,11 +11,13 @@ import UserAvatar from '../../Objects/UserAvatar' import AdminAuthorization from '@components/Security/AdminAuthorization' import { useLHSession } from '@components/Contexts/LHSessionContext' import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config' +import useFeatureFlag from '@components/Hooks/useFeatureFlag' function DashLeftMenu() { const org = useOrg() as any const session = useLHSession() as any const [loading, setLoading] = React.useState(true) + const isPaymentsEnabled = useFeatureFlag({ path: ['features', 'payments', 'enabled'], defaultValue: false }) function waitForEverythingToLoad() { if (org && session) { @@ -112,6 +114,16 @@ function DashLeftMenu() { + {isPaymentsEnabled && ( + + + + + + )}
-
- +
+ + + + + + - - + + +
Assignments + + + + Payments + + diff --git a/apps/web/components/Dashboard/UI/BreadCrumbs.tsx b/apps/web/components/Dashboard/Misc/BreadCrumbs.tsx similarity index 85% rename from apps/web/components/Dashboard/UI/BreadCrumbs.tsx rename to apps/web/components/Dashboard/Misc/BreadCrumbs.tsx index f242f315..9e0d9dde 100644 --- a/apps/web/components/Dashboard/UI/BreadCrumbs.tsx +++ b/apps/web/components/Dashboard/Misc/BreadCrumbs.tsx @@ -1,11 +1,11 @@ 'use client'; import { useOrg } from '@components/Contexts/OrgContext'; -import { Backpack, Book, ChevronRight, School, User, Users } from 'lucide-react' +import { Backpack, Book, ChevronRight, CreditCard, School, User, Users } from 'lucide-react' import Link from 'next/link' import React from 'react' type BreadCrumbsProps = { - type: 'courses' | 'user' | 'users' | 'org' | 'orgusers' | 'assignments' + type: 'courses' | 'user' | 'users' | 'org' | 'orgusers' | 'assignments' | 'payments' last_breadcrumb?: string } @@ -65,6 +65,15 @@ function BreadCrumbs(props: BreadCrumbsProps) { ) : ( '' )} + {props.type == 'payments' ? ( +
+ {' '} + + Payments +
+ ) : ( + '' + )}
{props.last_breadcrumb ? : ''}
diff --git a/apps/web/components/Dashboard/UI/CourseOverviewTop.tsx b/apps/web/components/Dashboard/Misc/CourseOverviewTop.tsx similarity index 100% rename from apps/web/components/Dashboard/UI/CourseOverviewTop.tsx rename to apps/web/components/Dashboard/Misc/CourseOverviewTop.tsx diff --git a/apps/web/components/Dashboard/UI/SaveState.tsx b/apps/web/components/Dashboard/Misc/SaveState.tsx similarity index 100% rename from apps/web/components/Dashboard/UI/SaveState.tsx rename to apps/web/components/Dashboard/Misc/SaveState.tsx diff --git a/apps/web/components/Dashboard/Course/EditCourseAccess/EditCourseAccess.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess.tsx similarity index 98% rename from apps/web/components/Dashboard/Course/EditCourseAccess/EditCourseAccess.tsx rename to apps/web/components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess.tsx index a682bef5..1770b180 100644 --- a/apps/web/components/Dashboard/Course/EditCourseAccess/EditCourseAccess.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess.tsx @@ -1,7 +1,7 @@ import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext' import LinkToUserGroup from '@components/Objects/Modals/Dash/EditCourseAccess/LinkToUserGroup' -import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal' -import Modal from '@components/StyledElements/Modal/Modal' +import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' +import Modal from '@components/Objects/StyledElements/Modal/Modal' import { getAPIUrl } from '@services/config/config' import { unLinkResourcesToUserGroup } from '@services/usergroups/usergroups' import { swrFetcher } from '@services/utils/ts/requests' diff --git a/apps/web/components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx similarity index 97% rename from apps/web/components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral.tsx rename to apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx index 8535fd83..e8c8cbf4 100644 --- a/apps/web/components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx @@ -3,13 +3,13 @@ import FormLayout, { FormLabelAndMessage, Input, Textarea, -} from '@components/StyledElements/Form/Form'; +} from '@components/Objects/StyledElements/Form/Form'; import { useFormik } from 'formik'; import { AlertTriangle } from 'lucide-react'; import * as Form from '@radix-ui/react-form'; import React, { useEffect, useState } from 'react'; -import { useCourse, useCourseDispatch } from '../../../Contexts/CourseContext'; import ThumbnailUpdate from './ThumbnailUpdate'; +import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext'; type EditCourseStructureProps = { orgslug: string diff --git a/apps/web/components/Dashboard/Course/EditCourseGeneral/ThumbnailUpdate.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx similarity index 100% rename from apps/web/components/Dashboard/Course/EditCourseGeneral/ThumbnailUpdate.tsx rename to apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx diff --git a/apps/web/components/Dashboard/Course/EditCourseGeneral/UnsplashImagePicker.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/UnsplashImagePicker.tsx similarity index 84% rename from apps/web/components/Dashboard/Course/EditCourseGeneral/UnsplashImagePicker.tsx rename to apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/UnsplashImagePicker.tsx index 662974af..68abb289 100644 --- a/apps/web/components/Dashboard/Course/EditCourseGeneral/UnsplashImagePicker.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/UnsplashImagePicker.tsx @@ -3,6 +3,7 @@ import { createApi } from 'unsplash-js'; import { Search, X, Cpu, Briefcase, GraduationCap, Heart, Palette, Plane, Utensils, Dumbbell, Music, Shirt, Book, Building, Bike, Camera, Microscope, Coins, Coffee, Gamepad, Flower} from 'lucide-react'; +import Modal from '@components/Objects/StyledElements/Modal/Modal'; const unsplash = createApi({ accessKey: process.env.NEXT_PUBLIC_UNSPLASH_ACCESS_KEY as string, @@ -36,9 +37,10 @@ const predefinedLabels = [ interface UnsplashImagePickerProps { onSelect: (imageUrl: string) => void; onClose: () => void; + isOpen?: boolean; } -const UnsplashImagePicker: React.FC = ({ onSelect, onClose }) => { +const UnsplashImagePicker: React.FC = ({ onSelect, onClose, isOpen = true }) => { const [query, setQuery] = useState(''); const [images, setImages] = useState([]); const [page, setPage] = useState(1); @@ -54,8 +56,6 @@ const UnsplashImagePicker: React.FC = ({ onSelect, onC }); if (result && result.response) { setImages(prevImages => pageNum === 1 ? result.response.results : [...prevImages, ...result.response.results]); - } else { - console.error('Unexpected response structure:', result); } } catch (error) { console.error('Error fetching images:', error); @@ -97,16 +97,10 @@ const UnsplashImagePicker: React.FC = ({ onSelect, onC onClose(); }; - return ( -
-
-
-

Choose an image from Unsplash

- -
-
+ const modalContent = ( +
+
+
= ({ onSelect, onC />
-
+
{predefinedLabels.map(label => ( ))}
+
+ +
{images.map(image => (
@@ -135,7 +132,7 @@ const UnsplashImagePicker: React.FC = ({ onSelect, onC src={image.urls.small} alt={image.alt_description} className="absolute inset-0 w-full h-full object-cover rounded-lg cursor-pointer hover:opacity-80 transition-opacity" - onClick={() => handleImageSelect(image.urls.full)} + onClick={() => handleImageSelect(image.urls.regular)} />
))} @@ -144,7 +141,7 @@ const UnsplashImagePicker: React.FC = ({ onSelect, onC {!loading && images.length > 0 && ( @@ -152,6 +149,18 @@ const UnsplashImagePicker: React.FC = ({ onSelect, onC
); + + return ( + + ); }; // Custom debounce function diff --git a/apps/web/components/Dashboard/Course/EditCourseStructure/Buttons/NewActivityButton.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/Buttons/NewActivityButton.tsx similarity index 98% rename from apps/web/components/Dashboard/Course/EditCourseStructure/Buttons/NewActivityButton.tsx rename to apps/web/components/Dashboard/Pages/Course/EditCourseStructure/Buttons/NewActivityButton.tsx index 896632ff..63e8f0f5 100644 --- a/apps/web/components/Dashboard/Course/EditCourseStructure/Buttons/NewActivityButton.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/Buttons/NewActivityButton.tsx @@ -1,6 +1,6 @@ import { useCourse } from '@components/Contexts/CourseContext' import NewActivityModal from '@components/Objects/Modals/Activities/Create/NewActivity' -import Modal from '@components/StyledElements/Modal/Modal' +import Modal from '@components/Objects/StyledElements/Modal/Modal' import { getAPIUrl } from '@services/config/config' import { createActivity, diff --git a/apps/web/components/Dashboard/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx similarity index 90% rename from apps/web/components/Dashboard/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx rename to apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx index 642f9162..294fc2fb 100644 --- a/apps/web/components/Dashboard/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx @@ -1,4 +1,4 @@ -import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal' +import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' import { getAPIUrl, getUriWithOrg } from '@services/config/config' import { deleteActivity, updateActivity } from '@services/courses/activities' import { revalidateTags } from '@services/utils/ts/requests' @@ -7,6 +7,7 @@ import { Eye, File, FilePenLine, + FileSymlink, Globe, Lock, MoreVertical, @@ -27,6 +28,7 @@ import { useOrg } from '@components/Contexts/OrgContext' import { useCourse } from '@components/Contexts/CourseContext' import toast from 'react-hot-toast' import { useMediaQuery } from 'usehooks-ts' +import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' type ActivitiyElementProps = { orgslug: string @@ -176,24 +178,26 @@ function ActivityElement(props: ActivitiyElementProps) { )} {!props.activity.published ? 'Publish' : 'Unpublish'} - - - Preview - +
+ + + + + {/* Delete Button */} - {!isMobile && Delete} } functionToExecute={() => deleteActivityUI()} diff --git a/apps/web/components/Dashboard/Course/EditCourseStructure/DraggableElements/ChapterElement.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ChapterElement.tsx similarity index 98% rename from apps/web/components/Dashboard/Course/EditCourseStructure/DraggableElements/ChapterElement.tsx rename to apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ChapterElement.tsx index b3d27980..f035b937 100644 --- a/apps/web/components/Dashboard/Course/EditCourseStructure/DraggableElements/ChapterElement.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ChapterElement.tsx @@ -1,4 +1,4 @@ -import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal' +import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' import { Hexagon, MoreHorizontal, diff --git a/apps/web/components/Dashboard/Course/EditCourseStructure/EditCourseStructure.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure.tsx similarity index 98% rename from apps/web/components/Dashboard/Course/EditCourseStructure/EditCourseStructure.tsx rename to apps/web/components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure.tsx index f9b5b24a..834a0d1f 100644 --- a/apps/web/components/Dashboard/Course/EditCourseStructure/EditCourseStructure.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure.tsx @@ -13,7 +13,7 @@ import { useCourseDispatch, } from '@components/Contexts/CourseContext' import { Hexagon } from 'lucide-react' -import Modal from '@components/StyledElements/Modal/Modal' +import Modal from '@components/Objects/StyledElements/Modal/Modal' import NewChapterModal from '@components/Objects/Modals/Chapters/NewChapter' import { useLHSession } from '@components/Contexts/LHSessionContext' diff --git a/apps/web/components/Dashboard/Org/OrgEditGeneral/OrgEditGeneral.tsx b/apps/web/components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral.tsx similarity index 99% rename from apps/web/components/Dashboard/Org/OrgEditGeneral/OrgEditGeneral.tsx rename to apps/web/components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral.tsx index 36c0fb20..032d4e69 100644 --- a/apps/web/components/Dashboard/Org/OrgEditGeneral/OrgEditGeneral.tsx +++ b/apps/web/components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral.tsx @@ -12,7 +12,7 @@ import { useRouter } from 'next/navigation' import { useOrg } from '@components/Contexts/OrgContext' import { useLHSession } from '@components/Contexts/LHSessionContext' import { getOrgLogoMediaDirectory, getOrgThumbnailMediaDirectory } from '@services/media/media' -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs" import { Toaster, toast } from 'react-hot-toast'; import { constructAcceptValue } from '@/lib/constants'; diff --git a/apps/web/components/Dashboard/Pages/Payments/PaymentsConfigurationPage.tsx b/apps/web/components/Dashboard/Pages/Payments/PaymentsConfigurationPage.tsx new file mode 100644 index 00000000..f2192d5e --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Payments/PaymentsConfigurationPage.tsx @@ -0,0 +1,290 @@ +'use client'; +import React, { useState, useEffect } from 'react'; +import { useOrg } from '@components/Contexts/OrgContext'; +import { SiStripe } from '@icons-pack/react-simple-icons' +import { useLHSession } from '@components/Contexts/LHSessionContext'; +import { getPaymentConfigs, initializePaymentConfig, updatePaymentConfig, deletePaymentConfig, updateStripeAccountID, getStripeOnboardingLink } from '@services/payments/payments'; +import FormLayout, { ButtonBlack, Input, Textarea, FormField, FormLabelAndMessage, Flex } from '@components/Objects/StyledElements/Form/Form'; +import { AlertTriangle, BarChart2, Check, Coins, CreditCard, Edit, ExternalLink, Info, Loader2, RefreshCcw, Trash2, UnplugIcon } from 'lucide-react'; +import toast from 'react-hot-toast'; +import useSWR, { mutate } from 'swr'; +import Modal from '@components/Objects/StyledElements/Modal/Modal'; +import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'; +import { Button } from '@components/ui/button'; +import { Alert, AlertDescription, AlertTitle } from '@components/ui/alert'; +import { useRouter } from 'next/navigation'; +import { getUriWithoutOrg } from '@services/config/config'; + +const PaymentsConfigurationPage: React.FC = () => { + const org = useOrg() as any; + const session = useLHSession() as any; + const router = useRouter(); + const access_token = session?.data?.tokens?.access_token; + const { data: paymentConfigs, error, isLoading } = useSWR( + () => (org && access_token ? [`/payments/${org.id}/config`, access_token] : null), + ([url, token]) => getPaymentConfigs(org.id, token) + ); + + const stripeConfig = paymentConfigs?.find((config: any) => config.provider === 'stripe'); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isOnboarding, setIsOnboarding] = useState(false); + const [isOnboardingLoading, setIsOnboardingLoading] = useState(false); + + const enableStripe = async () => { + try { + setIsOnboarding(true); + const newConfig = { provider: 'stripe', enabled: true }; + const config = await initializePaymentConfig(org.id, newConfig, 'stripe', access_token); + toast.success('Stripe enabled successfully'); + mutate([`/payments/${org.id}/config`, access_token]); + } catch (error) { + console.error('Error enabling Stripe:', error); + toast.error('Failed to enable Stripe'); + } finally { + setIsOnboarding(false); + } + }; + + const editConfig = async () => { + setIsModalOpen(true); + }; + + const deleteConfig = async () => { + try { + await deletePaymentConfig(org.id, stripeConfig.id, access_token); + toast.success('Stripe configuration deleted successfully'); + mutate([`/payments/${org.id}/config`, access_token]); + } catch (error) { + console.error('Error deleting Stripe configuration:', error); + toast.error('Failed to delete Stripe configuration'); + } + }; + + const handleStripeOnboarding = async () => { + try { + setIsOnboardingLoading(true); + const { connect_url } = await getStripeOnboardingLink(org.id, access_token, getUriWithoutOrg('/payments/stripe/connect/oauth')); + window.open(connect_url, '_blank'); + } catch (error) { + console.error('Error getting onboarding link:', error); + toast.error('Failed to start Stripe onboarding'); + } finally { + setIsOnboardingLoading(false); + } + }; + + if (isLoading) { + return
Loading...
; + } + + if (error) { + return
Error loading payment configuration
; + } + + return ( +
+
+
+

Payments Configuration

+

Manage your organization payments configuration

+
+ + + + About the Stripe Integration + +
+
    +
  • + + Accept payments for courses and subscriptions +
  • +
  • + + Manage recurring billing and subscriptions +
  • +
  • + + Handle multiple currencies and payment methods +
  • +
  • + + Access detailed payment analytics +
  • +
+
+ + Learn more about Stripe + + +
+
+ +
+ {stripeConfig ? ( +
+
+ +
+
+ Stripe + {stripeConfig.provider_specific_id && stripeConfig.active ? ( +
+
+ Connected +
+ ) : ( +
+
+ Not Connected +
+ )} +
+ + {stripeConfig.provider_specific_id ? + `Linked Account: ${stripeConfig.provider_specific_id}` : + 'Account ID not configured'} + +
+
+
+ {(!stripeConfig.provider_specific_id || !stripeConfig.active) && ( + + )} + + + Remove Connection + + } + functionToExecute={deleteConfig} + status="warning" + /> +
+
+ ) : ( + + )} +
+
+ {stripeConfig && ( + setIsModalOpen(false)} + /> + )} +
+ ); +}; + +interface EditStripeConfigModalProps { + orgId: number; + configId: string; + accessToken: string; + isOpen: boolean; + onClose: () => void; +} + +const EditStripeConfigModal: React.FC = ({ orgId, configId, accessToken, isOpen, onClose }) => { + const [stripeAccountId, setStripeAccountId] = useState(''); + + useEffect(() => { + const fetchConfig = async () => { + try { + const config = await getPaymentConfigs(orgId, accessToken); + const stripeConfig = config.find((c: any) => c.id === configId); + if (stripeConfig && stripeConfig.provider_specific_id) { + setStripeAccountId(stripeConfig.provider_specific_id || ''); + } + } catch (error) { + console.error('Error fetching Stripe configuration:', error); + toast.error('Failed to load existing configuration'); + } + }; + + if (isOpen) { + fetchConfig(); + } + }, [isOpen, orgId, configId, accessToken]); + + const handleSubmit = async () => { + try { + const stripe_config = { + stripe_account_id: stripeAccountId, + }; + await updateStripeAccountID(orgId, stripe_config, accessToken); + toast.success('Configuration updated successfully'); + mutate([`/payments/${orgId}/config`, accessToken]); + onClose(); + } catch (error) { + console.error('Error updating config:', error); + toast.error('Failed to update configuration'); + } + }; + + return ( + + + + setStripeAccountId(e.target.value)} + placeholder="acct_..." + /> + + + + Save + + + + } + /> + ); +}; + +export default PaymentsConfigurationPage; diff --git a/apps/web/components/Dashboard/Pages/Payments/PaymentsCustomersPage.tsx b/apps/web/components/Dashboard/Pages/Payments/PaymentsCustomersPage.tsx new file mode 100644 index 00000000..e62d7e39 --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Payments/PaymentsCustomersPage.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import { useOrg } from '@components/Contexts/OrgContext' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import useSWR from 'swr' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@components/ui/table" +import { getOrgCustomers } from '@services/payments/payments' +import { Badge } from '@components/ui/badge' +import PageLoading from '@components/Objects/Loaders/PageLoading' +import { RefreshCcw, SquareCheck } from 'lucide-react' +import { getUserAvatarMediaDirectory } from '@services/media/media' +import UserAvatar from '@components/Objects/UserAvatar' +import { usePaymentsEnabled } from '@hooks/usePaymentsEnabled' +import UnconfiguredPaymentsDisclaimer from '@components/Pages/Payments/UnconfiguredPaymentsDisclaimer' + +interface PaymentUserData { + payment_user_id: number; + user: { + username: string; + first_name: string; + last_name: string; + email: string; + avatar_image: string; + user_uuid: string; + }; + product: { + name: string; + description: string; + product_type: string; + amount: number; + currency: string; + }; + status: string; + creation_date: string; +} + +function PaymentsUsersTable({ data }: { data: PaymentUserData[] }) { + if (!data || data.length === 0) { + return ( +
+ No customers found +
+ ); + } + + return ( + + + + User + Product + Type + Amount + Status + Purchase Date + + + + {data.map((item) => ( + + +
+ +
+ + {item.user.first_name || item.user.username} + + {item.user.email} +
+
+
+ {item.product.name} + +
+ {item.product.product_type === 'subscription' ? ( + + + Subscription + + ) : ( + + + One-time + + )} +
+
+ + {new Intl.NumberFormat('en-US', { + style: 'currency', + currency: item.product.currency + }).format(item.product.amount)} + + + + {item.status} + + + + {new Date(item.creation_date).toLocaleDateString()} + +
+ ))} +
+
+ ); +} + +function PaymentsCustomersPage() { + const org = useOrg() as any + const session = useLHSession() as any + const access_token = session?.data?.tokens?.access_token + const { isEnabled, isLoading } = usePaymentsEnabled() + + const { data: customers, error, isLoading: customersLoading } = useSWR( + org ? [`/payments/${org.id}/customers`, access_token] : null, + ([url, token]) => getOrgCustomers(org.id, token) + ) + + if (!isEnabled && !isLoading) { + return ( + + ) + } + + if (isLoading || customersLoading) return + if (error) return
Error loading customers
+ if (!customers) return
No customer data available
+ + return ( +
+
+

Customers

+

View and manage your customer information

+
+ + +
+ ) +} + +export default PaymentsCustomersPage \ No newline at end of file diff --git a/apps/web/components/Dashboard/Pages/Payments/PaymentsProductPage.tsx b/apps/web/components/Dashboard/Pages/Payments/PaymentsProductPage.tsx new file mode 100644 index 00000000..7ccf8c97 --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Payments/PaymentsProductPage.tsx @@ -0,0 +1,312 @@ +'use client'; +import React, { useState, useEffect } from 'react' +import currencyCodes from 'currency-codes'; +import { useOrg } from '@components/Contexts/OrgContext'; +import { useLHSession } from '@components/Contexts/LHSessionContext'; +import useSWR, { mutate } from 'swr'; +import { getProducts, updateProduct, archiveProduct } from '@services/payments/products'; +import { Plus, Pencil, Info, RefreshCcw, SquareCheck, ChevronDown, ChevronUp, Archive } from 'lucide-react'; +import Modal from '@components/Objects/StyledElements/Modal/Modal'; +import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'; +import toast from 'react-hot-toast'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@components/ui/select" +import { Button } from "@components/ui/button" +import { Input } from "@components/ui/input" +import { Textarea } from "@components/ui/textarea" +import { Formik, Form, Field, ErrorMessage } from 'formik'; +import * as Yup from 'yup'; +import { Label } from '@components/ui/label'; +import { Badge } from '@components/ui/badge'; +import { getPaymentConfigs } from '@services/payments/payments'; +import ProductLinkedCourses from './SubComponents/ProductLinkedCourses'; +import { usePaymentsEnabled } from '@hooks/usePaymentsEnabled'; +import UnconfiguredPaymentsDisclaimer from '@components/Pages/Payments/UnconfiguredPaymentsDisclaimer'; +import CreateProductForm from './SubComponents/CreateProductForm'; + +const validationSchema = Yup.object().shape({ + name: Yup.string().required('Name is required'), + description: Yup.string().required('Description is required'), + amount: Yup.number().min(0, 'Amount must be positive').required('Amount is required'), + benefits: Yup.string(), + currency: Yup.string().required('Currency is required'), +}); + +function PaymentsProductPage() { + const org = useOrg() as any; + const session = useLHSession() as any; + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [editingProductId, setEditingProductId] = useState(null); + const [expandedProducts, setExpandedProducts] = useState<{ [key: string]: boolean }>({}); + const [isStripeEnabled, setIsStripeEnabled] = useState(false); + const { isEnabled, isLoading } = usePaymentsEnabled(); + + const { data: products, error } = useSWR( + () => org && session ? [`/payments/${org.id}/products`, session.data?.tokens?.access_token] : null, + ([url, token]) => getProducts(org.id, token) + ); + + const { data: paymentConfigs, error: paymentConfigError } = useSWR( + () => org && session ? [`/payments/${org.id}/config`, session.data?.tokens?.access_token] : null, + ([url, token]) => getPaymentConfigs(org.id, token) + ); + + useEffect(() => { + if (paymentConfigs) { + const stripeConfig = paymentConfigs.find((config: any) => config.provider === 'stripe'); + setIsStripeEnabled(!!stripeConfig); + } + }, [paymentConfigs]); + + const handleArchiveProduct = async (productId: string) => { + const res = await archiveProduct(org.id, productId, session.data?.tokens?.access_token); + mutate([`/payments/${org.id}/products`, session.data?.tokens?.access_token]); + if (res.status === 200) { + toast.success('Product archived successfully'); + } else { + toast.error(res.data.detail); + } + } + + const toggleProductExpansion = (productId: string) => { + setExpandedProducts(prev => ({ + ...prev, + [productId]: !prev[productId] + })); + }; + + if (!isEnabled && !isLoading) { + return ( + + ); + } + + if (error) return
Failed to load products
; + if (!products) return
Loading...
; + + return ( +
+
+ + + setIsCreateModalOpen(false)} /> + } + /> + +
+ {products.data.map((product: any) => ( +
+ {editingProductId === product.id ? ( + setEditingProductId(null)} + onCancel={() => setEditingProductId(null)} + /> + ) : ( +
+
+
+ + {product.product_type === 'subscription' ? : } + {product.product_type === 'subscription' ? 'Subscription' : 'One-time payment'} + +

{product.name}

+
+
+ + + + + } + functionToExecute={() => handleArchiveProduct(product.id)} + status="warning" + /> +
+
+
+
+

+ {product.description} +

+ {product.benefits && ( +
+

Benefits:

+

+ {product.benefits} +

+
+ )} +
+
+
+ +
+ +
+ Price: + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: product.currency }).format(product.amount)} + +
+
+ )} +
+ ))} +
+ {products.data.length === 0 && ( +
+ +

No products available. Create a new product to get started.

+
+ )} + +
+ +
+
+
+ ) +} + +const EditProductForm = ({ product, onSuccess, onCancel }: { product: any, onSuccess: () => void, onCancel: () => void }) => { + const org = useOrg() as any; + const session = useLHSession() as any; + const [currencies, setCurrencies] = useState<{ code: string; name: string }[]>([]); + + useEffect(() => { + const allCurrencies = currencyCodes.data.map(currency => ({ + code: currency.code, + name: `${currency.code} - ${currency.currency}` + })); + setCurrencies(allCurrencies); + }, []); + + const initialValues = { + name: product.name, + description: product.description, + amount: product.amount, + benefits: product.benefits || '', + currency: product.currency || '', + product_type: product.product_type, + }; + + const handleSubmit = async (values: typeof initialValues, { setSubmitting }: { setSubmitting: (isSubmitting: boolean) => void }) => { + try { + await updateProduct(org.id, product.id, values, session.data?.tokens?.access_token); + mutate([`/payments/${org.id}/products`, session.data?.tokens?.access_token]); + onSuccess(); + toast.success('Product updated successfully'); + } catch (error) { + toast.error('Failed to update product'); + } finally { + setSubmitting(false); + } + }; + + return ( + + {({ isSubmitting, values, setFieldValue }) => ( +
+
+
+ + + +
+ +
+ + + +
+ +
+
+ + + +
+
+ + + +
+
+ +
+ + + +
+
+ +
+ + +
+
+ )} +
+ ); +}; + +export default PaymentsProductPage diff --git a/apps/web/components/Dashboard/Pages/Payments/SubComponents/CreateProductForm.tsx b/apps/web/components/Dashboard/Pages/Payments/SubComponents/CreateProductForm.tsx new file mode 100644 index 00000000..25efeb62 --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Payments/SubComponents/CreateProductForm.tsx @@ -0,0 +1,184 @@ +import React, { useEffect, useState } from 'react'; +import { useOrg } from '@components/Contexts/OrgContext'; +import { useLHSession } from '@components/Contexts/LHSessionContext'; +import { createProduct } from '@services/payments/products'; +import { Formik, Form, Field, ErrorMessage } from 'formik'; +import * as Yup from 'yup'; +import toast from 'react-hot-toast'; +import { mutate } from 'swr'; +import { Button } from "@components/ui/button"; +import { Input } from "@components/ui/input"; +import { Textarea } from "@components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@components/ui/select"; +import { Label } from "@components/ui/label"; +import currencyCodes from 'currency-codes'; + +const validationSchema = Yup.object().shape({ + name: Yup.string().required('Name is required'), + description: Yup.string().required('Description is required'), + amount: Yup.number() + .min(1, 'Amount must be greater than zero') + .required('Amount is required'), + benefits: Yup.string(), + currency: Yup.string().required('Currency is required'), + product_type: Yup.string().oneOf(['one_time', 'subscription']).required('Product type is required'), + price_type: Yup.string().oneOf(['fixed_price', 'customer_choice']).required('Price type is required'), +}); + +interface ProductFormValues { + name: string; + description: string; + product_type: 'one_time' | 'subscription'; + price_type: 'fixed_price' | 'customer_choice'; + benefits: string; + amount: number; + currency: string; +} + +const CreateProductForm: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => { + const org = useOrg() as any; + const session = useLHSession() as any; + const [currencies, setCurrencies] = useState<{ code: string; name: string }[]>([]); + + useEffect(() => { + const allCurrencies = currencyCodes.data.map(currency => ({ + code: currency.code, + name: `${currency.code} - ${currency.currency}` + })); + setCurrencies(allCurrencies); + }, []); + + const initialValues: ProductFormValues = { + name: '', + description: '', + product_type: 'one_time', + price_type: 'fixed_price', + benefits: '', + amount: 1, + currency: 'USD', + }; + + const handleSubmit = async (values: ProductFormValues, { setSubmitting, resetForm }: any) => { + try { + const res = await createProduct(org.id, values, session.data?.tokens?.access_token); + if (res.success) { + toast.success('Product created successfully'); + mutate([`/payments/${org.id}/products`, session.data?.tokens?.access_token]); + resetForm(); + onSuccess(); + } else { + toast.error('Failed to create product'); + } + } catch (error) { + console.error('Error creating product:', error); + toast.error('An error occurred while creating the product'); + } finally { + setSubmitting(false); + } + }; + + return ( + + {({ isSubmitting, values, setFieldValue }) => ( +
+
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+
+ + + +
+
+ + + +
+
+ +
+ + + +
+
+ +
+ +
+
+ )} +
+ ); +}; + +export default CreateProductForm; diff --git a/apps/web/components/Dashboard/Pages/Payments/SubComponents/LinkCourseModal.tsx b/apps/web/components/Dashboard/Pages/Payments/SubComponents/LinkCourseModal.tsx new file mode 100644 index 00000000..9052f239 --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Payments/SubComponents/LinkCourseModal.tsx @@ -0,0 +1,143 @@ +import React, { useState } from 'react'; +import { useOrg } from '@components/Contexts/OrgContext'; +import { useLHSession } from '@components/Contexts/LHSessionContext'; +import { linkCourseToProduct } from '@services/payments/products'; +import { Button } from "@components/ui/button"; +import { Input } from "@components/ui/input"; +import { Search } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { mutate } from 'swr'; +import useSWR from 'swr'; +import { getOrgCourses } from '@services/courses/courses'; +import { getCoursesLinkedToProduct } from '@services/payments/products'; +import Link from 'next/link'; +import { getCourseThumbnailMediaDirectory } from '@services/media/media'; +import { getUriWithOrg } from '@services/config/config'; + +interface LinkCourseModalProps { + productId: string; + onSuccess: () => void; +} + +interface CoursePreviewProps { + course: { + id: string; + name: string; + description: string; + thumbnail_image: string; + course_uuid: string; + }; + orgslug: string; + onLink: (courseId: string) => void; + isLinked: boolean; +} + +const CoursePreview = ({ course, orgslug, onLink, isLinked }: CoursePreviewProps) => { + const org = useOrg() as any; + + const thumbnailImage = course.thumbnail_image + ? getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image) + : '../empty_thumbnail.png'; + + return ( +
+ {/* Thumbnail */} +
+ + {/* Content */} +
+

+ {course.name} +

+

+ {course.description} +

+
+ + {/* Action Button */} +
+ {isLinked ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default function LinkCourseModal({ productId, onSuccess }: LinkCourseModalProps) { + const [searchTerm, setSearchTerm] = useState(''); + const org = useOrg() as any; + const session = useLHSession() as any; + + const { data: courses } = useSWR( + () => org && session ? [org.slug, searchTerm, session.data?.tokens?.access_token] : null, + ([orgSlug, search, token]) => getOrgCourses(orgSlug, null, token) + ); + + const { data: linkedCourses } = useSWR( + () => org && session ? [`/payments/${org.id}/products/${productId}/courses`, session.data?.tokens?.access_token] : null, + ([_, token]) => getCoursesLinkedToProduct(org.id, productId, token) + ); + + const handleLinkCourse = async (courseId: string) => { + try { + const response = await linkCourseToProduct(org.id, productId, courseId, session.data?.tokens?.access_token); + if (response.success) { + mutate([`/payments/${org.id}/products`, session.data?.tokens?.access_token]); + toast.success('Course linked successfully'); + onSuccess(); + } else { + toast.error(response.data?.detail || 'Failed to link course'); + } + } catch (error) { + toast.error('Failed to link course'); + } + }; + + const isLinked = (courseId: string) => { + return linkedCourses?.data?.some((course: any) => course.id === courseId); + }; + + return ( +
+ + + {/* Course List */} +
+ {courses?.map((course: any) => ( + + ))} + + {/* Empty State */} + {(!courses || courses.length === 0) && ( +
+ No courses found +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/components/Dashboard/Pages/Payments/SubComponents/ProductLinkedCourses.tsx b/apps/web/components/Dashboard/Pages/Payments/SubComponents/ProductLinkedCourses.tsx new file mode 100644 index 00000000..8d07bcf0 --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Payments/SubComponents/ProductLinkedCourses.tsx @@ -0,0 +1,106 @@ +import React, { useEffect, useState } from 'react'; +import { getCoursesLinkedToProduct, unlinkCourseFromProduct } from '@services/payments/products'; +import { useLHSession } from '@components/Contexts/LHSessionContext'; +import { useOrg } from '@components/Contexts/OrgContext'; +import { Trash2, Plus, BookOpen } from 'lucide-react'; +import { Button } from "@components/ui/button"; +import toast from 'react-hot-toast'; +import { mutate } from 'swr'; +import Modal from '@components/Objects/StyledElements/Modal/Modal'; +import LinkCourseModal from './LinkCourseModal'; + +interface ProductLinkedCoursesProps { + productId: string; +} + +export default function ProductLinkedCourses({ productId }: ProductLinkedCoursesProps) { + const [linkedCourses, setLinkedCourses] = useState([]); + const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); + const session = useLHSession() as any; + const org = useOrg() as any; + + const fetchLinkedCourses = async () => { + try { + const response = await getCoursesLinkedToProduct(org.id, productId, session.data?.tokens?.access_token); + setLinkedCourses(response.data || []); + } catch (error) { + toast.error('Failed to fetch linked courses'); + } + }; + + const handleUnlinkCourse = async (courseId: string) => { + try { + const response = await unlinkCourseFromProduct(org.id, productId, courseId, session.data?.tokens?.access_token); + if (response.success) { + await fetchLinkedCourses(); + mutate([`/payments/${org.id}/products`, session.data?.tokens?.access_token]); + toast.success('Course unlinked successfully'); + } else { + toast.error(response.data?.detail || 'Failed to unlink course'); + } + } catch (error) { + toast.error('Failed to unlink course'); + } + }; + + useEffect(() => { + if (org && session && productId) { + fetchLinkedCourses(); + } + }, [org, session, productId]); + + return ( +
+
+

Linked Courses

+ { + setIsLinkModalOpen(false); + fetchLinkedCourses(); + }} + /> + } + dialogTrigger={ + + } + /> +
+ +
+ {linkedCourses.length === 0 ? ( +
+ + No courses linked yet +
+ ) : ( + linkedCourses.map((course) => ( +
+ {course.name} + +
+ )) + )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral.tsx b/apps/web/components/Dashboard/Pages/UserAccount/UserEditGeneral/UserEditGeneral.tsx similarity index 100% rename from apps/web/components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral.tsx rename to apps/web/components/Dashboard/Pages/UserAccount/UserEditGeneral/UserEditGeneral.tsx diff --git a/apps/web/components/Dashboard/UserAccount/UserEditPassword/UserEditPassword.tsx b/apps/web/components/Dashboard/Pages/UserAccount/UserEditPassword/UserEditPassword.tsx similarity index 100% rename from apps/web/components/Dashboard/UserAccount/UserEditPassword/UserEditPassword.tsx rename to apps/web/components/Dashboard/Pages/UserAccount/UserEditPassword/UserEditPassword.tsx diff --git a/apps/web/components/Dashboard/Users/OrgAccess/OrgAccess.tsx b/apps/web/components/Dashboard/Pages/Users/OrgAccess/OrgAccess.tsx similarity index 98% rename from apps/web/components/Dashboard/Users/OrgAccess/OrgAccess.tsx rename to apps/web/components/Dashboard/Pages/Users/OrgAccess/OrgAccess.tsx index 9c609c77..b5221e66 100644 --- a/apps/web/components/Dashboard/Users/OrgAccess/OrgAccess.tsx +++ b/apps/web/components/Dashboard/Pages/Users/OrgAccess/OrgAccess.tsx @@ -1,6 +1,6 @@ import { useOrg } from '@components/Contexts/OrgContext' import PageLoading from '@components/Objects/Loaders/PageLoading' -import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal' +import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' import { getAPIUrl, getUriWithOrg } from '@services/config/config' import { swrFetcher } from '@services/utils/ts/requests' import { Globe, Ticket, UserSquare, Users, X } from 'lucide-react' @@ -14,7 +14,7 @@ import { } from '@services/organizations/invites' import toast from 'react-hot-toast' import { useRouter } from 'next/navigation' -import Modal from '@components/StyledElements/Modal/Modal' +import Modal from '@components/Objects/StyledElements/Modal/Modal' import OrgInviteCodeGenerate from '@components/Objects/Modals/Dash/OrgAccess/OrgInviteCodeGenerate' import { useLHSession } from '@components/Contexts/LHSessionContext' diff --git a/apps/web/components/Dashboard/Users/OrgUserGroups/OrgUserGroups.tsx b/apps/web/components/Dashboard/Pages/Users/OrgUserGroups/OrgUserGroups.tsx similarity index 98% rename from apps/web/components/Dashboard/Users/OrgUserGroups/OrgUserGroups.tsx rename to apps/web/components/Dashboard/Pages/Users/OrgUserGroups/OrgUserGroups.tsx index d0d7b7c2..ba586ada 100644 --- a/apps/web/components/Dashboard/Users/OrgUserGroups/OrgUserGroups.tsx +++ b/apps/web/components/Dashboard/Pages/Users/OrgUserGroups/OrgUserGroups.tsx @@ -4,8 +4,8 @@ import { useOrg } from '@components/Contexts/OrgContext' import AddUserGroup from '@components/Objects/Modals/Dash/OrgUserGroups/AddUserGroup' import EditUserGroup from '@components/Objects/Modals/Dash/OrgUserGroups/EditUserGroup' import ManageUsers from '@components/Objects/Modals/Dash/OrgUserGroups/ManageUsers' -import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal' -import Modal from '@components/StyledElements/Modal/Modal' +import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' +import Modal from '@components/Objects/StyledElements/Modal/Modal' import { getAPIUrl } from '@services/config/config' import { deleteUserGroup } from '@services/usergroups/usergroups' import { swrFetcher } from '@services/utils/ts/requests' diff --git a/apps/web/components/Dashboard/Users/OrgUsers/OrgUsers.tsx b/apps/web/components/Dashboard/Pages/Users/OrgUsers/OrgUsers.tsx similarity index 96% rename from apps/web/components/Dashboard/Users/OrgUsers/OrgUsers.tsx rename to apps/web/components/Dashboard/Pages/Users/OrgUsers/OrgUsers.tsx index c25e5d84..7d5bee0b 100644 --- a/apps/web/components/Dashboard/Users/OrgUsers/OrgUsers.tsx +++ b/apps/web/components/Dashboard/Pages/Users/OrgUsers/OrgUsers.tsx @@ -2,9 +2,9 @@ import { useLHSession } from '@components/Contexts/LHSessionContext' import { useOrg } from '@components/Contexts/OrgContext' import PageLoading from '@components/Objects/Loaders/PageLoading' import RolesUpdate from '@components/Objects/Modals/Dash/OrgUsers/RolesUpdate' -import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal' -import Modal from '@components/StyledElements/Modal/Modal' -import Toast from '@components/StyledElements/Toast/Toast' +import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' +import Modal from '@components/Objects/StyledElements/Modal/Modal' +import Toast from '@components/Objects/StyledElements/Toast/Toast' import { getAPIUrl } from '@services/config/config' import { removeUserFromOrg } from '@services/organizations/orgs' import { swrFetcher } from '@services/utils/ts/requests' diff --git a/apps/web/components/Dashboard/Users/OrgUsersAdd/OrgUsersAdd.tsx b/apps/web/components/Dashboard/Pages/Users/OrgUsersAdd/OrgUsersAdd.tsx similarity index 98% rename from apps/web/components/Dashboard/Users/OrgUsersAdd/OrgUsersAdd.tsx rename to apps/web/components/Dashboard/Pages/Users/OrgUsersAdd/OrgUsersAdd.tsx index b724a350..496d25d9 100644 --- a/apps/web/components/Dashboard/Users/OrgUsersAdd/OrgUsersAdd.tsx +++ b/apps/web/components/Dashboard/Pages/Users/OrgUsersAdd/OrgUsersAdd.tsx @@ -1,8 +1,8 @@ import { useLHSession } from '@components/Contexts/LHSessionContext' import { useOrg } from '@components/Contexts/OrgContext' import PageLoading from '@components/Objects/Loaders/PageLoading' -import Toast from '@components/StyledElements/Toast/Toast' -import ToolTip from '@components/StyledElements/Tooltip/Tooltip' +import Toast from '@components/Objects/StyledElements/Toast/Toast' +import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' import { getAPIUrl } from '@services/config/config' import { inviteBatchUsers } from '@services/organizations/invites' import { swrFetcher } from '@services/utils/ts/requests' diff --git a/apps/web/components/Hooks/useFeatureFlag.tsx b/apps/web/components/Hooks/useFeatureFlag.tsx new file mode 100644 index 00000000..97623043 --- /dev/null +++ b/apps/web/components/Hooks/useFeatureFlag.tsx @@ -0,0 +1,36 @@ +import { useOrg } from '@components/Contexts/OrgContext' +import { useEffect, useState } from 'react' + +type FeatureType = { + path: string[] + defaultValue?: boolean +} + +function useFeatureFlag(feature: FeatureType) { + const org = useOrg() as any + const [isEnabled, setIsEnabled] = useState(!!feature.defaultValue) + + useEffect(() => { + if (org?.config?.config) { + let currentValue = org.config.config + + // Traverse the path to get the feature flag value + for (const key of feature.path) { + if (currentValue && typeof currentValue === 'object') { + currentValue = currentValue[key] + } else { + currentValue = feature.defaultValue || false + break + } + } + + setIsEnabled(!!currentValue) + } else { + setIsEnabled(!!feature.defaultValue) + } + }, [org, feature]) + + return isEnabled +} + +export default useFeatureFlag \ No newline at end of file diff --git a/apps/web/components/AI/Hooks/useGetAIFeatures.tsx b/apps/web/components/Hooks/useGetAIFeatures.tsx similarity index 100% rename from apps/web/components/AI/Hooks/useGetAIFeatures.tsx rename to apps/web/components/Hooks/useGetAIFeatures.tsx diff --git a/apps/web/components/Hooks/usePaymentsEnabled.tsx b/apps/web/components/Hooks/usePaymentsEnabled.tsx new file mode 100644 index 00000000..bc397928 --- /dev/null +++ b/apps/web/components/Hooks/usePaymentsEnabled.tsx @@ -0,0 +1,26 @@ +// hooks/usePaymentsEnabled.ts +import { useOrg } from '@components/Contexts/OrgContext'; +import { useLHSession } from '@components/Contexts/LHSessionContext'; +import useSWR from 'swr'; +import { getPaymentConfigs } from '@services/payments/payments'; + +export function usePaymentsEnabled() { + const org = useOrg() as any; + const session = useLHSession() as any; + const access_token = session?.data?.tokens?.access_token; + + const { data: paymentConfigs, error, isLoading } = useSWR( + org && access_token ? [`/payments/${org.id}/config`, access_token] : null, + ([url, token]) => getPaymentConfigs(org.id, token) + ); + + const isStripeEnabled = paymentConfigs?.some( + (config: any) => config.provider === 'stripe' && config.active + ); + + return { + isEnabled: !!isStripeEnabled, + isLoading, + error + }; +} \ No newline at end of file diff --git a/apps/web/components/Objects/Activities/AI/AIActivityAsk.tsx b/apps/web/components/Objects/Activities/AI/AIActivityAsk.tsx index e0b3c454..69a344b8 100644 --- a/apps/web/components/Objects/Activities/AI/AIActivityAsk.tsx +++ b/apps/web/components/Objects/Activities/AI/AIActivityAsk.tsx @@ -15,7 +15,7 @@ import { useAIChatBot, useAIChatBotDispatch, } from '@components/Contexts/AI/AIChatBotContext' -import useGetAIFeatures from '../../../AI/Hooks/useGetAIFeatures' +import useGetAIFeatures from '../../../Hooks/useGetAIFeatures' import UserAvatar from '@components/Objects/UserAvatar' type AIActivityAskProps = { diff --git a/apps/web/components/Objects/Activities/DynamicCanva/AI/AICanvaToolkit.tsx b/apps/web/components/Objects/Activities/DynamicCanva/AI/AICanvaToolkit.tsx index 67692c0c..8a9bb0c9 100644 --- a/apps/web/components/Objects/Activities/DynamicCanva/AI/AICanvaToolkit.tsx +++ b/apps/web/components/Objects/Activities/DynamicCanva/AI/AICanvaToolkit.tsx @@ -4,7 +4,7 @@ import learnhouseAI_icon from 'public/learnhouse_ai_simple.png' import Image from 'next/image' import { BookOpen, FormInput, Languages, MoreVertical } from 'lucide-react' import { BubbleMenu } from '@tiptap/react' -import ToolTip from '@components/StyledElements/Tooltip/Tooltip' +import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' import { AIChatBotStateTypes, useAIChatBot, @@ -14,7 +14,7 @@ import { sendActivityAIChatMessage, startActivityAIChatSession, } from '@services/ai/ai' -import useGetAIFeatures from '../../../../AI/Hooks/useGetAIFeatures' +import useGetAIFeatures from '../../../../Hooks/useGetAIFeatures' import { useLHSession } from '@components/Contexts/LHSessionContext' type AICanvaToolkitProps = { diff --git a/apps/web/components/ContentPlaceHolder.tsx b/apps/web/components/Objects/ContentPlaceHolder.tsx similarity index 87% rename from apps/web/components/ContentPlaceHolder.tsx rename to apps/web/components/Objects/ContentPlaceHolder.tsx index 68c5aa18..b9cb6547 100644 --- a/apps/web/components/ContentPlaceHolder.tsx +++ b/apps/web/components/Objects/ContentPlaceHolder.tsx @@ -1,6 +1,6 @@ 'use client'; import React from 'react' -import useAdminStatus from './Hooks/useAdminStatus' +import useAdminStatus from '../Hooks/useAdminStatus' // Terrible name and terible implementation, need to be refactored asap diff --git a/apps/web/components/Objects/Courses/CourseActions/CoursePaidOptions.tsx b/apps/web/components/Objects/Courses/CourseActions/CoursePaidOptions.tsx new file mode 100644 index 00000000..c2537497 --- /dev/null +++ b/apps/web/components/Objects/Courses/CourseActions/CoursePaidOptions.tsx @@ -0,0 +1,161 @@ +import React, { useState } from 'react' +import { useOrg } from '@components/Contexts/OrgContext' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import useSWR from 'swr' +import { getProductsByCourse, getStripeProductCheckoutSession } from '@services/payments/products' +import { RefreshCcw, SquareCheck, ChevronDown, ChevronUp } from 'lucide-react' +import { Badge } from '@components/ui/badge' +import { Button } from '@components/ui/button' +import toast from 'react-hot-toast' +import { useRouter } from 'next/navigation' +import { getUriWithOrg } from '@services/config/config' + +interface CoursePaidOptionsProps { + course: { + id: string; + org_id: number; + } +} + +function CoursePaidOptions({ course }: CoursePaidOptionsProps) { + const org = useOrg() as any + const session = useLHSession() as any + const [expandedProducts, setExpandedProducts] = useState<{ [key: string]: boolean }>({}) + const [isProcessing, setIsProcessing] = useState<{ [key: string]: boolean }>({}) + const router = useRouter() + + const { data: linkedProducts, error } = useSWR( + () => org && session ? [`/payments/${course.org_id}/courses/${course.id}/products`, session.data?.tokens?.access_token] : null, + ([url, token]) => getProductsByCourse(course.org_id, course.id, token) + ) + + const handleCheckout = async (productId: number) => { + if (!session.data?.user) { + // Redirect to login if user is not authenticated + router.push(`/signup?orgslug=${org.slug}`) + return + } + + try { + setIsProcessing(prev => ({ ...prev, [productId]: true })) + const redirect_uri = getUriWithOrg(org.slug, '/courses') + const response = await getStripeProductCheckoutSession( + course.org_id, + productId, + redirect_uri, + session.data?.tokens?.access_token + ) + + if (response.success) { + router.push(response.data.checkout_url) + } else { + toast.error('Failed to initiate checkout process') + } + } catch (error) { + toast.error('An error occurred while processing your request') + } finally { + setIsProcessing(prev => ({ ...prev, [productId]: false })) + } + } + + const toggleProductExpansion = (productId: string) => { + setExpandedProducts(prev => ({ + ...prev, + [productId]: !prev[productId] + })) + } + + if (error) return
Failed to load product options
+ if (!linkedProducts) return
Loading...
+ + return ( +
+ {linkedProducts.data.map((product: any) => ( +
+
+
+ + {product.product_type === 'subscription' ? : } + + {product.product_type === 'subscription' ? 'Subscription' : 'One-time payment'} + {product.product_type === 'subscription' && ' (per month)'} + + +

{product.name}

+
+
+ +
+
+

+ {product.description} +

+ {product.benefits && ( +
+

Benefits:

+

+ {product.benefits} +

+
+ )} +
+
+ +
+ +
+ +
+ + {product.price_type === 'customer_choice' ? 'Minimum Price:' : 'Price:'} + +
+ + {new Intl.NumberFormat('en-US', { + style: 'currency', + currency: product.currency + }).format(product.amount)} + {product.product_type === 'subscription' && /month} + + {product.price_type === 'customer_choice' && ( + Choose your price + )} +
+
+ + +
+ ))} +
+ ) +} + +export default CoursePaidOptions diff --git a/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx b/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx new file mode 100644 index 00000000..bbcabe0d --- /dev/null +++ b/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx @@ -0,0 +1,257 @@ +import React, { useState, useEffect } from 'react' +import UserAvatar from '../../UserAvatar' +import { getUserAvatarMediaDirectory } from '@services/media/media' +import { removeCourse, startCourse } from '@services/courses/activity' +import { revalidateTags } from '@services/utils/ts/requests' +import { useRouter } from 'next/navigation' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import { useMediaQuery } from 'usehooks-ts' +import { getUriWithOrg } from '@services/config/config' +import { getProductsByCourse } from '@services/payments/products' +import { LogIn, LogOut, ShoppingCart, AlertCircle } from 'lucide-react' +import Modal from '@components/Objects/StyledElements/Modal/Modal' +import CoursePaidOptions from './CoursePaidOptions' +import { checkPaidAccess } from '@services/payments/payments' + +interface Author { + user_uuid: string + avatar_image: string + first_name: string + last_name: string + username: string +} + +interface CourseRun { + status: string + course_id: string +} + +interface Course { + id: string + authors: Author[] + trail?: { + runs: CourseRun[] + } +} + +interface CourseActionsProps { + courseuuid: string + orgslug: string + course: Course & { + org_id: number + } +} + +// Separate component for author display +const AuthorInfo = ({ author, isMobile }: { author: Author, isMobile: boolean }) => ( +
+ +
+
Author
+
+ {(author.first_name && author.last_name) ? ( +
+

{`${author.first_name} ${author.last_name}`}

+ + @{author.username} + +
+ ) : ( +
+

@{author.username}

+
+ )} +
+
+
+) + +const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { + const router = useRouter() + const session = useLHSession() as any + const [linkedProducts, setLinkedProducts] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isModalOpen, setIsModalOpen] = useState(false) + const [hasAccess, setHasAccess] = useState(null) + + const isStarted = course.trail?.runs?.some( + (run) => run.status === 'STATUS_IN_PROGRESS' && run.course_id === course.id + ) ?? false + + useEffect(() => { + const fetchLinkedProducts = async () => { + try { + const response = await getProductsByCourse( + course.org_id, + course.id, + session.data?.tokens?.access_token + ) + setLinkedProducts(response.data || []) + } catch (error) { + console.error('Failed to fetch linked products') + } finally { + setIsLoading(false) + } + } + + fetchLinkedProducts() + }, [course.id, course.org_id, session.data?.tokens?.access_token]) + + useEffect(() => { + const checkAccess = async () => { + if (!session.data?.user) return + try { + const response = await checkPaidAccess( + parseInt(course.id), + course.org_id, + session.data?.tokens?.access_token + ) + setHasAccess(response.has_access) + + } catch (error) { + console.error('Failed to check course access') + setHasAccess(false) + } + } + + if (linkedProducts.length > 0) { + checkAccess() + } + }, [course.id, course.org_id, session.data?.tokens?.access_token, linkedProducts]) + + const handleCourseAction = async () => { + if (!session.data?.user) { + router.push(getUriWithOrg(orgslug, '/signup?orgslug=' + orgslug)) + return + } + const action = isStarted ? removeCourse : startCourse + await action('course_' + courseuuid, orgslug, session.data?.tokens?.access_token) + await revalidateTags(['courses'], orgslug) + router.refresh() + } + + if (isLoading) { + return
+ } + + if (linkedProducts.length > 0) { + return ( +
+ {hasAccess ? ( + <> +
+
+
+

You Own This Course

+
+

+ You have purchased this course and have full access to all content. +

+
+ + + ) : ( +
+
+ +

Paid Course

+
+

+ This course requires purchase to access its content. +

+
+ )} + + {!hasAccess && ( + <> + } + dialogTitle="Purchase Course" + dialogDescription="Select a payment option to access this course" + minWidth="sm" + /> + + + )} +
+ ) + } + + return ( + + ) +} + +function CoursesActions({ courseuuid, orgslug, course }: CourseActionsProps) { + const router = useRouter() + const session = useLHSession() as any + const isMobile = useMediaQuery('(max-width: 768px)') + + + return ( +
+ +
+ +
+
+ ) +} + +export default CoursesActions \ No newline at end of file diff --git a/apps/web/components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer.tsx b/apps/web/components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer.tsx new file mode 100644 index 00000000..bb576046 --- /dev/null +++ b/apps/web/components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { AlertCircle, ShoppingCart } from 'lucide-react' +import CoursePaidOptions from './CoursePaidOptions' + +interface PaidCourseActivityProps { + course: any; +} + +function PaidCourseActivityDisclaimer({ course }: PaidCourseActivityProps) { + return ( +
+
+
+ +

Paid Content

+
+

+ This content requires a course purchase to access. +

+
+ +
+ ) +} + +export default PaidCourseActivityDisclaimer \ No newline at end of file diff --git a/apps/web/components/Objects/CourseUpdates/CourseUpdates.tsx b/apps/web/components/Objects/Courses/CourseUpdates/CourseUpdates.tsx similarity index 98% rename from apps/web/components/Objects/CourseUpdates/CourseUpdates.tsx rename to apps/web/components/Objects/Courses/CourseUpdates/CourseUpdates.tsx index a0709a6e..917f233b 100644 --- a/apps/web/components/Objects/CourseUpdates/CourseUpdates.tsx +++ b/apps/web/components/Objects/Courses/CourseUpdates/CourseUpdates.tsx @@ -8,7 +8,7 @@ import FormLayout, { FormLabelAndMessage, Input, Textarea, -} from '@components/StyledElements/Form/Form' +} from '@components/Objects/StyledElements/Form/Form' import { useCourse } from '@components/Contexts/CourseContext' import useSWR, { mutate } from 'swr' import { getAPIUrl } from '@services/config/config' @@ -17,7 +17,7 @@ import useAdminStatus from '@components/Hooks/useAdminStatus' import { useOrg } from '@components/Contexts/OrgContext' import { createCourseUpdate, deleteCourseUpdate } from '@services/courses/updates' import toast from 'react-hot-toast' -import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal' +import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; import { useLHSession } from '@components/Contexts/LHSessionContext' diff --git a/apps/web/components/Objects/Editor/AI/AIEditorToolkit.tsx b/apps/web/components/Objects/Editor/AI/AIEditorToolkit.tsx index 4b12b60b..e8dd3953 100644 --- a/apps/web/components/Objects/Editor/AI/AIEditorToolkit.tsx +++ b/apps/web/components/Objects/Editor/AI/AIEditorToolkit.tsx @@ -23,7 +23,7 @@ import { sendActivityAIChatMessage, startActivityAIChatSession, } from '@services/ai/ai' -import useGetAIFeatures from '@components/AI/Hooks/useGetAIFeatures' +import useGetAIFeatures from '@components/Hooks/useGetAIFeatures' import { useLHSession } from '@components/Contexts/LHSessionContext' type AIEditorToolkitProps = { diff --git a/apps/web/components/Objects/Editor/Editor.tsx b/apps/web/components/Objects/Editor/Editor.tsx index ddd40192..29d3fd8d 100644 --- a/apps/web/components/Objects/Editor/Editor.tsx +++ b/apps/web/components/Objects/Editor/Editor.tsx @@ -29,7 +29,7 @@ import Table from '@tiptap/extension-table' import TableCell from '@tiptap/extension-table-cell' import TableHeader from '@tiptap/extension-table-header' import TableRow from '@tiptap/extension-table-row' -import ToolTip from '@components/StyledElements/Tooltip/Tooltip' +import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' import Link from 'next/link' import { getCourseThumbnailMediaDirectory } from '@services/media/media' @@ -47,7 +47,7 @@ import java from 'highlight.js/lib/languages/java' import { CourseProvider } from '@components/Contexts/CourseContext' import { useLHSession } from '@components/Contexts/LHSessionContext' import AIEditorToolkit from './AI/AIEditorToolkit' -import useGetAIFeatures from '@components/AI/Hooks/useGetAIFeatures' +import useGetAIFeatures from '@components/Hooks/useGetAIFeatures' import Collaboration from '@tiptap/extension-collaboration' import CollaborationCursor from '@tiptap/extension-collaboration-cursor' import ActiveAvatars from './ActiveAvatars' diff --git a/apps/web/components/Objects/Editor/EditorWrapper.tsx b/apps/web/components/Objects/Editor/EditorWrapper.tsx index e51180a3..b9c3d7f1 100644 --- a/apps/web/components/Objects/Editor/EditorWrapper.tsx +++ b/apps/web/components/Objects/Editor/EditorWrapper.tsx @@ -3,7 +3,7 @@ import { default as React, useEffect, useRef, useState } from 'react' import Editor from './Editor' import { updateActivity } from '@services/courses/activities' import { toast } from 'react-hot-toast' -import Toast from '@components/StyledElements/Toast/Toast' +import Toast from '@components/Objects/StyledElements/Toast/Toast' import { OrgProvider } from '@components/Contexts/OrgContext' import { useLHSession } from '@components/Contexts/LHSessionContext' diff --git a/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx b/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx index 5819bad6..0dc54119 100644 --- a/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx +++ b/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx @@ -29,7 +29,7 @@ import { Video, } from 'lucide-react' import { SiYoutube } from '@icons-pack/react-simple-icons' -import ToolTip from '@components/StyledElements/Tooltip/Tooltip' +import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' export const ToolbarButtons = ({ editor, props }: any) => { if (!editor) { diff --git a/apps/web/components/Objects/Menu/Menu.tsx b/apps/web/components/Objects/Menus/OrgMenu.tsx similarity index 99% rename from apps/web/components/Objects/Menu/Menu.tsx rename to apps/web/components/Objects/Menus/OrgMenu.tsx index ec33ddd8..22eef5c2 100644 --- a/apps/web/components/Objects/Menu/Menu.tsx +++ b/apps/web/components/Objects/Menus/OrgMenu.tsx @@ -3,12 +3,12 @@ import React from 'react' import Link from 'next/link' import { getUriWithOrg } from '@services/config/config' import { HeaderProfileBox } from '@components/Security/HeaderProfileBox' -import MenuLinks from './MenuLinks' +import MenuLinks from './OrgMenuLinks' import { getOrgLogoMediaDirectory } from '@services/media/media' import { useLHSession } from '@components/Contexts/LHSessionContext' import { useOrg } from '@components/Contexts/OrgContext' -export const Menu = (props: any) => { +export const OrgMenu = (props: any) => { const orgslug = props.orgslug const session = useLHSession() as any; const access_token = session?.data?.tokens?.access_token; diff --git a/apps/web/components/Objects/Menu/MenuLinks.tsx b/apps/web/components/Objects/Menus/OrgMenuLinks.tsx similarity index 100% rename from apps/web/components/Objects/Menu/MenuLinks.tsx rename to apps/web/components/Objects/Menus/OrgMenuLinks.tsx diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Assignment.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Assignment.tsx index 17ea3fbe..f11a4323 100644 --- a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Assignment.tsx +++ b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Assignment.tsx @@ -7,7 +7,7 @@ import FormLayout, { FormMessage, Input, -} from '@components/StyledElements/Form/Form' +} from '@components/Objects/StyledElements/Form/Form' import * as Form from '@radix-ui/react-form' import { BarLoader } from 'react-spinners' import { useOrg } from '@components/Contexts/OrgContext' diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DocumentPdf.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DocumentPdf.tsx index 06d14e55..8c978577 100644 --- a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DocumentPdf.tsx +++ b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DocumentPdf.tsx @@ -5,7 +5,7 @@ import FormLayout, { FormLabel, FormMessage, Input, -} from '@components/StyledElements/Form/Form' +} from '@components/Objects/StyledElements/Form/Form' import React, { useState } from 'react' import * as Form from '@radix-ui/react-form' import BarLoader from 'react-spinners/BarLoader' diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DynamicCanva.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DynamicCanva.tsx index 7d6082ab..492d125c 100644 --- a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DynamicCanva.tsx +++ b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DynamicCanva.tsx @@ -6,7 +6,7 @@ import FormLayout, { FormMessage, Input, Textarea, -} from '@components/StyledElements/Form/Form' +} from '@components/Objects/StyledElements/Form/Form' import React, { useState } from 'react' import * as Form from '@radix-ui/react-form' import BarLoader from 'react-spinners/BarLoader' diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Video.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Video.tsx index 0d9ce796..3cc61bd1 100644 --- a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Video.tsx +++ b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Video.tsx @@ -5,7 +5,7 @@ import FormLayout, { FormLabel, FormMessage, Input, -} from '@components/StyledElements/Form/Form' +} from '@components/Objects/StyledElements/Form/Form' import React, { useState } from 'react' import * as Form from '@radix-ui/react-form' import BarLoader from 'react-spinners/BarLoader' diff --git a/apps/web/components/Objects/Modals/Chapters/NewChapter.tsx b/apps/web/components/Objects/Modals/Chapters/NewChapter.tsx index 9976a3a8..dfa4dd86 100644 --- a/apps/web/components/Objects/Modals/Chapters/NewChapter.tsx +++ b/apps/web/components/Objects/Modals/Chapters/NewChapter.tsx @@ -5,7 +5,7 @@ import FormLayout, { Textarea, FormLabel, ButtonBlack, -} from '@components/StyledElements/Form/Form' +} from '@components/Objects/StyledElements/Form/Form' import { FormMessage } from '@radix-ui/react-form' import * as Form from '@radix-ui/react-form' import React, { useState } from 'react' diff --git a/apps/web/components/Objects/Modals/Course/Create/CreateCourse.tsx b/apps/web/components/Objects/Modals/Course/Create/CreateCourse.tsx index f3246ee4..f20f1490 100644 --- a/apps/web/components/Objects/Modals/Course/Create/CreateCourse.tsx +++ b/apps/web/components/Objects/Modals/Course/Create/CreateCourse.tsx @@ -1,189 +1,262 @@ 'use client' +import { Input } from "@components/ui/input" +import { Textarea } from "@components/ui/textarea" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@components/ui/select" import FormLayout, { - ButtonBlack, - Flex, FormField, - FormLabel, - Input, - Textarea, -} from '@components/StyledElements/Form/Form' + FormLabelAndMessage, +} from '@components/Objects/StyledElements/Form/Form' import * as Form from '@radix-ui/react-form' -import { FormMessage } from '@radix-ui/react-form' import { createNewCourse } from '@services/courses/courses' import { getOrganizationContextInfoWithoutCredentials } from '@services/organizations/orgs' -import React, { useState } from 'react' +import React, { useEffect } from 'react' import { BarLoader } from 'react-spinners' import { revalidateTags } from '@services/utils/ts/requests' import { useRouter } from 'next/navigation' import { useLHSession } from '@components/Contexts/LHSessionContext' import toast from 'react-hot-toast' +import { useFormik } from 'formik' +import * as Yup from 'yup' +import { UploadCloud, Image as ImageIcon } from 'lucide-react' +import UnsplashImagePicker from "@components/Dashboard/Pages/Course/EditCourseGeneral/UnsplashImagePicker" + +const validationSchema = Yup.object().shape({ + name: Yup.string() + .required('Course name is required') + .max(100, 'Must be 100 characters or less'), + description: Yup.string() + .max(1000, 'Must be 1000 characters or less'), + learnings: Yup.string(), + tags: Yup.string(), + visibility: Yup.boolean(), + thumbnail: Yup.mixed().nullable() +}) function CreateCourseModal({ closeModal, orgslug }: any) { - const [isSubmitting, setIsSubmitting] = useState(false) - const session = useLHSession() as any; - const [name, setName] = React.useState('') - const [description, setDescription] = React.useState('') - const [learnings, setLearnings] = React.useState('') - const [visibility, setVisibility] = React.useState(true) - const [tags, setTags] = React.useState('') - const [isLoading, setIsLoading] = React.useState(false) - const [thumbnail, setThumbnail] = React.useState(null) as any const router = useRouter() - + const session = useLHSession() as any const [orgId, setOrgId] = React.useState(null) as any - const [org, setOrg] = React.useState(null) as any + const [showUnsplashPicker, setShowUnsplashPicker] = React.useState(false) + const [isUploading, setIsUploading] = React.useState(false) + + const formik = useFormik({ + initialValues: { + name: '', + description: '', + learnings: '', + visibility: true, + tags: '', + thumbnail: null + }, + validationSchema, + onSubmit: async (values, { setSubmitting }) => { + const toast_loading = toast.loading('Creating course...') + + try { + const res = await createNewCourse( + orgId, + { + name: values.name, + description: values.description, + tags: values.tags, + visibility: values.visibility + }, + values.thumbnail, + session.data?.tokens?.access_token + ) + + if (res.success) { + await revalidateTags(['courses'], orgslug) + toast.dismiss(toast_loading) + toast.success('Course created successfully') + + if (res.data.org_id === orgId) { + closeModal() + router.refresh() + await revalidateTags(['courses'], orgslug) + } + } else { + toast.error(res.data.detail) + } + } catch (error) { + toast.error('Failed to create course') + } finally { + setSubmitting(false) + } + } + }) const getOrgMetadata = async () => { const org = await getOrganizationContextInfoWithoutCredentials(orgslug, { revalidate: 360, tags: ['organizations'], }) - setOrgId(org.id) } - const handleNameChange = (event: React.ChangeEvent) => { - setName(event.target.value) - } - - const handleDescriptionChange = (event: React.ChangeEvent) => { - setDescription(event.target.value) - } - - const handleLearningsChange = (event: React.ChangeEvent) => { - setLearnings(event.target.value) - } - - const handleVisibilityChange = (event: React.ChangeEvent) => { - setVisibility(event.target.value) - } - - const handleTagsChange = (event: React.ChangeEvent) => { - setTags(event.target.value) - } - - const handleThumbnailChange = (event: React.ChangeEvent) => { - setThumbnail(event.target.files[0]) - } - - const handleSubmit = async (e: any) => { - e.preventDefault() - setIsSubmitting(true) - - let res = await createNewCourse( - orgId, - { name, description, tags, visibility }, - thumbnail, - session.data?.tokens?.access_token - ) - const toast_loading = toast.loading('Creating course...') - if (res.success) { - await revalidateTags(['courses'], orgslug) - setIsSubmitting(false) - toast.dismiss(toast_loading) - toast.success('Course created successfully') - - if (res.data.org_id == orgId) { - closeModal() - router.refresh() - await revalidateTags(['courses'], orgslug) - } - - } - else { - setIsSubmitting(false) - toast.error(res.data.detail) - } - } - - React.useEffect(() => { + useEffect(() => { if (orgslug) { getOrgMetadata() } - }, [isLoading, orgslug]) + }, [orgslug]) + + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (file) { + formik.setFieldValue('thumbnail', file) + } + } + + const handleUnsplashSelect = async (imageUrl: string) => { + setIsUploading(true) + try { + const response = await fetch(imageUrl) + const blob = await response.blob() + const file = new File([blob], 'unsplash_image.jpg', { type: 'image/jpeg' }) + formik.setFieldValue('thumbnail', file) + } catch (error) { + toast.error('Failed to load image from Unsplash') + } + setIsUploading(false) + } return ( - - - - Course name - - Please provide a course name - - + + + - - - - - - Course description - - Please provide a course description - - - -