From 1a3f7ae6ba264653c7ac0d277fd35fc0c785e799 Mon Sep 17 00:00:00 2001 From: Serhii Date: Fri, 20 Sep 2024 04:49:52 +0300 Subject: [PATCH] feature_protected_router_and_subscription --- frontend/.env.development | 3 +- frontend/.env.production | 3 +- frontend/eslint.config.js | 9 +- frontend/package-lock.json | 23 +++ frontend/package.json | 2 + frontend/src/App.tsx | 50 ++++++- frontend/src/ProtectedRoute.tsx | 37 +++++ frontend/src/api/api.ts | 21 +++ frontend/src/components/nav/Sidebar.tsx | 10 +- frontend/src/components/nav/TopNavbar.tsx | 113 ++++++++++++-- frontend/src/contexts/AuthContext.tsx | 9 +- frontend/src/pages/Collection.tsx | 6 +- frontend/src/pages/Collections.tsx | 6 +- frontend/src/pages/Home.tsx | 40 ++++- frontend/src/pages/Login.tsx | 6 +- frontend/src/pages/NotFound.tsx | 2 +- frontend/src/pages/SubscriptioinType.tsx | 173 ++++++++++++++++++++++ frontend/src/pages/Subscription.tsx | 41 +++++ frontend/src/types/auth.ts | 2 + linguaphoto/api/api.py | 3 +- linguaphoto/api/image.py | 8 +- linguaphoto/api/subscription.py | 45 ++++++ linguaphoto/api/user.py | 19 ++- linguaphoto/apprunner.yaml | 8 +- linguaphoto/crud/user.py | 4 + linguaphoto/models.py | 2 + linguaphoto/requirements.txt | 3 +- linguaphoto/schemas/user.py | 2 + linguaphoto/settings.py | 7 +- linguaphoto/utils/auth.py | 12 ++ 30 files changed, 602 insertions(+), 67 deletions(-) create mode 100644 frontend/src/ProtectedRoute.tsx create mode 100644 frontend/src/pages/SubscriptioinType.tsx create mode 100644 frontend/src/pages/Subscription.tsx create mode 100644 linguaphoto/api/subscription.py diff --git a/frontend/.env.development b/frontend/.env.development index 69a2392..b2f1926 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1 +1,2 @@ -REACT_APP_BACKEND_URL=http://localhost:8080 \ No newline at end of file +REACT_APP_BACKEND_URL=http://localhost:8080 +REACT_APP_STRIPE_API_KEY=pk_test_51Nv2EyKeTo38dsfeVEz53ZE9wFsAwwfQKEW5SROEb1ZbPg0rkX9xbZRi0ah7bjVQ7cuoy4dGH85rTag1TcLrhAi50043215z40 diff --git a/frontend/.env.production b/frontend/.env.production index 2ea8fee..de96c6e 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -1 +1,2 @@ -REACT_APP_BACKEND_URL=https://api.linguaphoto.com \ No newline at end of file +REACT_APP_BACKEND_URL=https://api.linguaphoto.com +REACT_APP_STRIPE_API_KEY=pk_live_51Nv2EyKeTo38dsfexsIGE1kPyPq8O7GpmsS6lFfmlOdfD5m70Mgq3c8Uq7CL0LvroMLybjxy0j28ErOKHwFQtFMf00p46WUYdr \ No newline at end of file diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index b40dda1..3057f5e 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -21,7 +21,14 @@ export default [ ...pluginReactConfig, rules: { ...pluginReactConfig.rules, - "react/react-in-jsx-scope": "off", + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", // Disable prop-types validation }, }), + { + files: ["*.ts", "*.tsx"], // Apply this rule to TypeScript files + rules: { + "react/prop-types": "off", // Ensure prop-types are off for TypeScript files + }, + }, ]; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e1e5b76..945e035 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.6.0", + "@stripe/react-stripe-js": "^2.8.0", + "@stripe/stripe-js": "^4.5.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -4154,6 +4156,27 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@stripe/react-stripe-js": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.8.0.tgz", + "integrity": "sha512-Vf1gNEuBxA9EtxiLghm2ZWmgbADNMJw4HW6eolUu0DON/6mZvWZgk0KHolN0sozNJwYp0i/8hBsDBcBUWcvnbw==", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-4.5.0.tgz", + "integrity": "sha512-dMOzc58AOlsF20nYM/avzV8RFhO/vgYTY7ajLMH6mjlnZysnOHZxsECQvjEmL8Q/ukPwHkOnxSPW/QGCCnp7XA==", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 11819e6..8e2c971 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,8 @@ "private": true, "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.6.0", + "@stripe/react-stripe-js": "^2.8.0", + "@stripe/stripe-js": "^4.5.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3314a36..0baee0e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,10 @@ import Collections from "pages/Collections"; import Home from "pages/Home"; import LoginPage from "pages/Login"; import NotFound from "pages/NotFound"; +import SubscriptionTypePage from "pages/SubscriptioinType"; +import SubscriptionCancelPage from "pages/Subscription"; import Test from "pages/Test"; +import PrivateRoute from "ProtectedRoute"; import { Container } from "react-bootstrap"; import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; import "./App.css"; @@ -29,17 +32,56 @@ const App = () => { } /> } /> } /> - } /> - } /> + } + requiredSubscription={true} // Set true if subscription is required for this route + /> + } + /> + } + requiredSubscription={true} // Set true if subscription is required for this route + /> + } + /> } + element={ + } + requiredSubscription={true} // Set true if subscription is required for this route + /> + } /> } + element={ + } + requiredSubscription={true} // Set true if subscription is required for this route + /> + } + /> + } + requiredSubscription={true} // Set true if subscription is required for this route + /> + } /> } /> + } + /> } /> diff --git a/frontend/src/ProtectedRoute.tsx b/frontend/src/ProtectedRoute.tsx new file mode 100644 index 0000000..438242c --- /dev/null +++ b/frontend/src/ProtectedRoute.tsx @@ -0,0 +1,37 @@ +import { useAuth } from "contexts/AuthContext"; +import { useEffect } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +interface PrivateRouteProps { + element: JSX.Element; + requiredSubscription?: boolean; // Optional flag for subscription requirement +} + +const PrivateRoute: React.FC = ({ + element, + requiredSubscription = false, +}) => { + const { auth } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + if (auth) + if (!auth?.is_auth) { + // Redirect to login if not authenticated + navigate("/login", { replace: true, state: { from: location } }); + } else if (requiredSubscription && !auth?.is_subscription) { + // Redirect to subscription page if subscription is required and not active + navigate("/subscription_type", { replace: true }); + } + }, [auth, requiredSubscription, navigate, location]); + + if (!auth?.is_auth || (requiredSubscription && !auth?.is_subscription)) { + // Render nothing while redirecting + return null; + } + + return element; +}; + +export default PrivateRoute; diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index b4c3e4b..1d8f916 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -5,6 +5,13 @@ export interface CollectionCreateFragment { title: string; description: string; } +// Define the types for the API response and request payload +export interface SubscriptionResponse { + success: boolean; + error?: string; + requires_action?: boolean; + payment_intent_client_secret?: string; +} export class Api { public api: AxiosInstance; @@ -75,4 +82,18 @@ export class Api { ); return response.data; } + + public async createSubscription( + payment_method_id: string, + email: string, + name: string, + ): Promise { + // Send payment method to the backend for subscription creation + const { data } = await this.api.post("/create_subscription", { + payment_method_id, + email, + name, + }); + return data; + } } diff --git a/frontend/src/components/nav/Sidebar.tsx b/frontend/src/components/nav/Sidebar.tsx index b2d166e..8e12698 100644 --- a/frontend/src/components/nav/Sidebar.tsx +++ b/frontend/src/components/nav/Sidebar.tsx @@ -70,7 +70,7 @@ interface SidebarProps { const Sidebar = ({ show, onClose }: SidebarProps) => { const navigate = useNavigate(); - const { is_auth, signout } = useAuth(); + const { auth, signout } = useAuth(); return (
{show ? ( @@ -106,7 +106,7 @@ const Sidebar = ({ show, onClose }: SidebarProps) => { }} size="md" /> - {is_auth ? ( + {auth?.is_auth ? ( } @@ -120,10 +120,10 @@ const Sidebar = ({ show, onClose }: SidebarProps) => { <> )} } onClick={() => { - navigate("/privacy"); + navigate("/subscription"); onClose(); }} size="md" @@ -134,7 +134,7 @@ const Sidebar = ({ show, onClose }: SidebarProps) => {
    - {is_auth ? ( + {auth?.is_auth ? ( } diff --git a/frontend/src/components/nav/TopNavbar.tsx b/frontend/src/components/nav/TopNavbar.tsx index 46cc88c..4f04e4f 100644 --- a/frontend/src/components/nav/TopNavbar.tsx +++ b/frontend/src/components/nav/TopNavbar.tsx @@ -1,37 +1,120 @@ +import { useAuth } from "contexts/AuthContext"; import { useTheme } from "hooks/theme"; import { useState } from "react"; import { Container, Nav, Navbar } from "react-bootstrap"; -import { GearFill, MoonFill, SunFill } from "react-bootstrap-icons"; -import { Link } from "react-router-dom"; +import { MoonFill, SunFill } from "react-bootstrap-icons"; +import { + FaBars, + FaLock, + FaSignInAlt, + FaSignOutAlt, + FaThList, +} from "react-icons/fa"; +import { Link, useLocation } from "react-router-dom"; import Sidebar from "./Sidebar"; const TopNavbar = () => { const [showSidebar, setShowSidebar] = useState(false); const { theme, setTheme } = useTheme(); - + const location = useLocation(); // To determine the active link + const { auth, signout } = useAuth(); return ( <> - - + + LinguaPhoto -
    - setTheme(theme === "dark" ? "light" : "dark")} - > - {theme === "dark" ? : } - - setShowSidebar(true)}> - - +
    + {/* Main Navigation Links */} +
    + {" "} + {/* Hidden on small screens */} + {auth?.is_auth ? ( + <> + + Collections + + + + Subscription + + { + // Handle logout logic here + signout(); + }} + > + Logout + + + ) : ( + { + // Handle logout logic here + signout(); + }} + > + Login / Sign Up + + )} +
    + + {/* Theme Toggle and Sidebar */} +
    + setTheme(theme === "dark" ? "light" : "dark")} + className="flex items-center text-lg text-gray-800 dark:text-gray-300" + > + {theme === "dark" ? : } + + + setShowSidebar(true)} + className="flex items-center text-lg text-gray-800 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-300 lg:hidden" // Show only on small screens + > + {/* Hamburger Button */} + +
    + setShowSidebar(false)} /> ); diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index e3a02ed..88dcabf 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -10,7 +10,6 @@ import React, { import { Response } from "types/auth"; interface AuthContextType { - is_auth: boolean; auth: Response | null; setAuth: React.Dispatch>; signout: () => void; @@ -20,11 +19,9 @@ const AuthContext = createContext(undefined); const AuthProvider = ({ children }: { children: ReactNode }) => { const [auth, setAuth] = useState(null); - const [is_auth, setFlag] = useState(false); const signout = () => { localStorage.removeItem("token"); setAuth({}); - setFlag(false); }; useEffect(() => { const token = localStorage.getItem("token"); @@ -32,7 +29,7 @@ const AuthProvider = ({ children }: { children: ReactNode }) => { const fetch_data = async (token: string) => { try { const response = await read_me(token); - if (response) setAuth(response); + setAuth(response); } catch { return; } @@ -43,14 +40,12 @@ const AuthProvider = ({ children }: { children: ReactNode }) => { useEffect(() => { if (auth?.token) { localStorage.setItem("token", auth.token); - setFlag(true); } - }, [auth]); + }, [auth?.token]); return ( { const [currentTranscriptionIndex, setCurrentTranscriptionIndex] = useState(0); const [currentImage, setCurrentImage] = useState(null); const [collection, setCollection] = useState(null); - const { auth, is_auth } = useAuth(); + const { auth } = useAuth(); const { startLoading, stopLoading } = useLoading(); const [showUploadModal, setShowUploadModal] = useState(false); const [showDeleteImageModal, setShowDeleteImageModal] = useState(false); @@ -77,7 +77,7 @@ const CollectionPage: React.FC = () => { // Simulate fetching data for the edit page (mocking API call) useEffect(() => { - if (id && is_auth) { + if (id && auth?.is_auth) { startLoading(); const asyncfunction = async () => { const collection = await API.getCollection(id); @@ -86,7 +86,7 @@ const CollectionPage: React.FC = () => { }; asyncfunction(); } - }, [id, is_auth]); + }, [id, auth]); useEffect(() => { if (translatedImages.length > 0) { diff --git a/frontend/src/pages/Collections.tsx b/frontend/src/pages/Collections.tsx index 43d6f25..45af850 100644 --- a/frontend/src/pages/Collections.tsx +++ b/frontend/src/pages/Collections.tsx @@ -12,7 +12,7 @@ import { Collection } from "types/model"; const Collections = () => { const [collections, setCollection] = useState | []>([]); - const { is_auth, auth } = useAuth(); + const { auth } = useAuth(); const [showModal, setShowModal] = useState(false); const { startLoading, stopLoading } = useLoading(); const [delete_ID, setDeleteID] = useState(String); @@ -37,7 +37,7 @@ const Collections = () => { }; useEffect(() => { - if (is_auth) { + if (auth?.is_auth) { const asyncfunction = async () => { startLoading(); const collections = await API.getAllCollections(); @@ -46,7 +46,7 @@ const Collections = () => { }; asyncfunction(); } - }, [is_auth]); + }, [auth]); const apiClient: AxiosInstance = useMemo( () => diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 21cff40..1c83be4 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,18 +1,42 @@ -import { Col, Container, Row } from "react-bootstrap"; - import avatar from "assets/avatar.png"; +import { Col, Container, Row } from "react-bootstrap"; const Home = () => { return ( - - - + + + {/* Text Section */} +

    LinguaPhoto

    Visual language learning for everyone!

    - {/* */} + {/* GoogleAuthComponent placeholder */} - - Avatar + + {/* Image Section */} + + Avatar
    diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index cddf805..805ddf9 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -11,12 +11,12 @@ const LoginPage: React.FC = () => { const [password, setPassword] = useState(""); const [username, setName] = useState(""); const { startLoading, stopLoading } = useLoading(); - const { is_auth, setAuth } = useAuth(); + const { auth, setAuth } = useAuth(); const navigate = useNavigate(); const { addAlert } = useAlertQueue(); useEffect(() => { - if (is_auth) navigate("/collections"); - }, [is_auth]); + if (auth?.is_auth) navigate("/collections"); + }, [auth]); // Toggle between login and signup forms const handleSwitch = () => { setIsSignup(!isSignup); diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx index 9a2c506..9b39a7c 100644 --- a/frontend/src/pages/NotFound.tsx +++ b/frontend/src/pages/NotFound.tsx @@ -2,7 +2,7 @@ import { Col, Row } from "react-bootstrap"; const NotFound = () => { return ( -
    +

    404 Not Found

    diff --git a/frontend/src/pages/SubscriptioinType.tsx b/frontend/src/pages/SubscriptioinType.tsx new file mode 100644 index 0000000..1fb8a16 --- /dev/null +++ b/frontend/src/pages/SubscriptioinType.tsx @@ -0,0 +1,173 @@ +import { + CardElement, + Elements, + useElements, + useStripe, +} from "@stripe/react-stripe-js"; + +import { loadStripe } from "@stripe/stripe-js"; +import { Api } from "api/api"; +import axios, { AxiosInstance } from "axios"; +import { useAuth } from "contexts/AuthContext"; +import { useLoading } from "contexts/LoadingContext"; +import { useAlertQueue } from "hooks/alerts"; +import { useTheme } from "hooks/theme"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_API_KEY || ""); +const CheckoutForm = () => { + const stripe = useStripe(); + const elements = useElements(); + const [error_message, setError] = useState(""); + const [name, setName] = useState(""); // Cardholder Name + const [email, setEmail] = useState(""); // Email Address + const { auth, setAuth } = useAuth(); + const { theme } = useTheme(); + const { addAlert } = useAlertQueue(); + const { startLoading, stopLoading } = useLoading(); + const navigate = useNavigate(); + const apiClient: AxiosInstance = useMemo( + () => + axios.create({ + baseURL: process.env.REACT_APP_BACKEND_URL, // Base URL for all requests + timeout: 10000, // Request timeout (in milliseconds) + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${auth?.token}`, // Add any default headers you need + }, + }), + [auth?.token], + ); + useEffect(() => { + if (auth?.email) setEmail(auth.email); + }, [auth?.email]); + + const API = useMemo(() => new Api(apiClient), [apiClient]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (elements == null || stripe == null) return; + const cardElement = elements.getElement(CardElement); + try { + // Create a payment method + const { error, paymentMethod } = await stripe.createPaymentMethod({ + type: "card", + card: cardElement!, + billing_details: { name, email }, + }); + + if (error) { + setError(error.message); + return; + } + startLoading(); + // Send payment method to the backend for subscription creation + const data = await API.createSubscription(paymentMethod.id, email, name); + stopLoading(); + if (data.success) { + // Handle successful subscription (e.g., redirect or show success message) + setAuth({ ...auth, is_subscription: true }); + addAlert("You have been subscribed successfully!", "success"); + navigate("/collections"); + } else { + setError(data.error); + } /* eslint-disable */ + } catch (error: any) { + /* eslint-enable */ + setError( + error.response?.data?.message || + "Subscription failed. Please try again.", + ); + } + }; + + const cardStyle = { + base: { + fontSize: "16px", + color: theme === "dark" ? "#ffffff" : "#32325d", + "::placeholder": { color: theme === "dark" ? "#aab7c4" : "#bfbfbf" }, + }, + invalid: { color: "#fa755a" }, + }; + + return ( +
    +

    Subscribe

    + +
    + +
    + +
    +
    + +
    + + setName(e.target.value)} + className="w-full p-2 border rounded" + placeholder="John Doe" + required + /> +
    + +
    + + +
    + +
    + +
    + + Monthly: $29.99 + +
    + + + + {error_message && ( +
    {error_message}
    + )} +
    + ); +}; + +const SubscriptionPage = () => { + return ( +
    + + + +
    + ); +}; + +export default SubscriptionPage; diff --git a/frontend/src/pages/Subscription.tsx b/frontend/src/pages/Subscription.tsx new file mode 100644 index 0000000..44390b9 --- /dev/null +++ b/frontend/src/pages/Subscription.tsx @@ -0,0 +1,41 @@ +import React from "react"; + +const SubscriptionCancelPage: React.FC = () => { + return ( +
    +

    + This page is on developing. will be updated soon +

    +
    + {/* Current Plan Section */} +
    +

    + Current Plan +

    +

    + You have been subscribed to the{" "} + + Premium Plan + + . +

    + +
    + + {/* Billing Cycle Section */} +
    +

    + Current Billing Cycle +

    +

    + 2023.05.05 - 2023.06.05 +

    +
    +
    +
    + ); +}; + +export default SubscriptionCancelPage; diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index a8e7174..17603a2 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -12,4 +12,6 @@ export interface Response { username?: string; email?: string; token?: string | null; + is_subscription?: boolean; + is_auth?: boolean; } diff --git a/linguaphoto/api/api.py b/linguaphoto/api/api.py index 050fd2e..ad7d47f 100644 --- a/linguaphoto/api/api.py +++ b/linguaphoto/api/api.py @@ -12,7 +12,7 @@ to handle routing for the entire API. """ -from api import collection, image, user +from api import collection, image, subscription, user from fastapi import APIRouter # Create a new API router @@ -22,6 +22,7 @@ router.include_router(user.router) router.include_router(collection.router) router.include_router(image.router) +router.include_router(subscription.router) # Define a root endpoint that returns a simple message diff --git a/linguaphoto/api/image.py b/linguaphoto/api/image.py index d41711f..08558b6 100644 --- a/linguaphoto/api/image.py +++ b/linguaphoto/api/image.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from models import Image from pydantic import BaseModel -from utils.auth import get_current_user_id +from utils.auth import get_current_user_id, subscription_validate class TranslateFramgement(BaseModel): @@ -22,6 +22,7 @@ async def upload_image( file: UploadFile = File(...), id: Annotated[str, Form()] = "", user_id: str = Depends(get_current_user_id), + is_subscribed: bool = Depends(subscription_validate), image_crud: ImageCrud = Depends(), ) -> Image: """Upload Image and create new Image.""" @@ -61,7 +62,10 @@ async def delete_image( @router.post("/translate", response_model=List[Image]) async def translate( - data: TranslateFramgement, user_id: str = Depends(get_current_user_id), image_crud: ImageCrud = Depends() + data: TranslateFramgement, + user_id: str = Depends(get_current_user_id), + image_crud: ImageCrud = Depends(), + is_subscribed: bool = Depends(subscription_validate), ) -> List[Image]: async with image_crud: images = await image_crud.translate(data.images, user_id=user_id) diff --git a/linguaphoto/api/subscription.py b/linguaphoto/api/subscription.py new file mode 100644 index 0000000..6269538 --- /dev/null +++ b/linguaphoto/api/subscription.py @@ -0,0 +1,45 @@ +"""Collection API.""" + +import stripe +from crud.user import UserCrud +from fastapi import APIRouter, Depends, HTTPException +from settings import settings +from utils.auth import get_current_user_id + +router = APIRouter() + +stripe.api_key = settings.stripe_key + +# Your existing price_id on Stripe +price_id = settings.stripe_price_id + + +@router.post("/create_subscription") +async def subscribe(data: dict, user_id: str = Depends(get_current_user_id), user_crud: UserCrud = Depends()) -> dict: + try: + # Create a customer if it doesn't exist + customer = stripe.Customer.create( + email=data["email"], + name=data["name"], + payment_method=data["payment_method_id"], + invoice_settings={"default_payment_method": data["payment_method_id"]}, + ) + + # Create a subscription for the customer + subscription = stripe.Subscription.create( + customer=customer.id, + items=[{"price": price_id}], # This is your existing product price ID + expand=["latest_invoice.payment_intent"], + ) + # Check if the subscription requires further action (e.g., 3D Secure) + if subscription["latest_invoice"]["payment_intent"]["status"] == "requires_action": + return { + "requires_action": True, + "payment_intent_client_secret": subscription["latest_invoice"]["payment_intent"]["client_secret"], + } + async with user_crud: + await user_crud.update_user(user_id, {"is_subscription": True}) + return {"success": True} + + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/linguaphoto/api/user.py b/linguaphoto/api/user.py index 56725a6..8dc3b21 100644 --- a/linguaphoto/api/user.py +++ b/linguaphoto/api/user.py @@ -24,10 +24,18 @@ async def signup(user: UserSignupFragment, user_crud: UserCrud = Depends()) -> d async with user_crud: new_user = await user_crud.create_user_from_email(user) if new_user is None: - return None + print( + UserSigninRespondFragment( + token="", username=user.username, email=user.email, is_subscription=False, is_auth=False + ).model_dump() + ) + return UserSigninRespondFragment( + token="", username=user.username, email=user.email, is_subscription=False, is_auth=False + ).model_dump() token = create_access_token({"id": new_user.id}, timedelta(hours=24)) - res_user = UserSigninRespondFragment(token=token, username=user.username, email=user.email) - return res_user.model_dump() + res_user = new_user.model_dump() + res_user.update({"token": token, "is_auth": True}) + return res_user @router.post("/signin", response_model=UserSigninRespondFragment | None) @@ -42,7 +50,7 @@ async def signin(user: UserSigninFragment, user_crud: UserCrud = Depends()) -> d res_user = await user_crud.get_user_by_email(user.email) user_dict = res_user.__dict__ token = create_access_token({"id": user_dict["id"]}, timedelta(hours=24)) - user_dict.update({"token": token}) + user_dict.update({"token": token, "is_auth": True}) return user_dict else: raise HTTPException(status_code=422, detail="Could not validate credentials") @@ -63,5 +71,6 @@ async def get_me(token: str = Depends(oauth2_schema), user_crud: UserCrud = Depe if user is None: raise HTTPException(status_code=422, detail="user not found") dict_user = user.model_dump() - dict_user.update({"token": token}) + dict_user.update({"token": token, "is_auth": True}) + print(dict_user) return dict_user diff --git a/linguaphoto/apprunner.yaml b/linguaphoto/apprunner.yaml index 14f6554..207ec9b 100644 --- a/linguaphoto/apprunner.yaml +++ b/linguaphoto/apprunner.yaml @@ -24,10 +24,14 @@ run: value: "https://media.linguaphoto.com" - name: KEY_PAIR_ID value: "K1DH6VMVUTIVWJ" + - name: STRIPE_PRODUCT_PRICE_ID + value: "price_1Q0ZaZKeTo38dsfe1D6G8SCg" secrets: - name: AWS_ACCESS_KEY_ID value-from: "arn:aws:secretsmanager:us-east-1:725596835855:secret:linguaphoto-u59FHw:id::" - name: AWS_SECRET_ACCESS_KEY value-from: "arn:aws:secretsmanager:us-east-1:725596835855:secret:linguaphoto-u59FHw:key::" - # - name: AWS_SECRET_ACCESS_KEY - # value-from: "arn:aws:secretsmanager:us-east-1:725596835855:secret:linguaphoto-u59FHw:openai_key::" + - name: OPENAI_API_KEY + value-from: "arn:aws:secretsmanager:us-east-1:725596835855:secret:linguaphoto-u59FHw:openai_key::" + - name: STRIPE_API_KEY + value-from: "arn:aws:secretsmanager:us-east-1:725596835855:secret:linguaphoto-u59FHw:stripe_private_key::" \ No newline at end of file diff --git a/linguaphoto/crud/user.py b/linguaphoto/crud/user.py index d9869b3..1e424c3 100644 --- a/linguaphoto/crud/user.py +++ b/linguaphoto/crud/user.py @@ -31,3 +31,7 @@ async def verify_user_by_email(self, user: UserSigninFragment) -> bool: return user_instance.verify_password(user.password) else: raise ValueError + + async def update_user(self, id: str, data: dict) -> User | None: + user = await self._update_item(id, User, data) + return user diff --git a/linguaphoto/models.py b/linguaphoto/models.py index 3e94648..a5febe4 100644 --- a/linguaphoto/models.py +++ b/linguaphoto/models.py @@ -31,6 +31,7 @@ class User(LinguaBaseModel): username: str email: str password_hash: str + is_subscription: bool @classmethod def create(cls, user: UserSignupFragment) -> Self: @@ -39,6 +40,7 @@ def create(cls, user: UserSignupFragment) -> Self: id=str(uuid4()), username=user.username, email=user.email, + is_subscription=False, password_hash=hashpw(user.password.encode("utf-8"), gensalt()).decode("utf-8"), ) diff --git a/linguaphoto/requirements.txt b/linguaphoto/requirements.txt index 69885e5..d57a88c 100644 --- a/linguaphoto/requirements.txt +++ b/linguaphoto/requirements.txt @@ -38,4 +38,5 @@ python-dotenv #AI openai -requests \ No newline at end of file +requests +stripe \ No newline at end of file diff --git a/linguaphoto/schemas/user.py b/linguaphoto/schemas/user.py index a869398..5f8481d 100644 --- a/linguaphoto/schemas/user.py +++ b/linguaphoto/schemas/user.py @@ -26,3 +26,5 @@ class UserSigninRespondFragment(BaseModel): token: str username: str email: EmailStr + is_subscription: bool + is_auth: bool diff --git a/linguaphoto/settings.py b/linguaphoto/settings.py index 4735394..4aab1d2 100644 --- a/linguaphoto/settings.py +++ b/linguaphoto/settings.py @@ -25,10 +25,9 @@ class Settings: aws_region_name = os.getenv("AWS_REGION") aws_access_key_id = os.getenv("AWS_ACCESS_KEY_ID") aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY") - openai_key = os.getenv( - "OPENAI_API_KEY", - "sk-svcacct-PFETCFHtqmHOmIpP_IAyQfBGz5LOpvC6Zudj7d5Wcdp9WjJT4ImAxuotGcpyT3BlbkFJRbtswQqIxYHam9TN13mCM04_OTZE-v8z-Rw1WEcwzyZqW_GcK0PNNyFp6BcA", - ) + openai_key = os.getenv("OPENAI_API_KEY") + stripe_key = os.getenv("STRIPE_API_KEY") + stripe_price_id = os.getenv("STRIPE_PRODUCT_PRICE_ID") settings = Settings() diff --git a/linguaphoto/utils/auth.py b/linguaphoto/utils/auth.py index b339a6b..7082d9d 100644 --- a/linguaphoto/utils/auth.py +++ b/linguaphoto/utils/auth.py @@ -15,6 +15,7 @@ from typing import Union import jwt +from crud.user import UserCrud from fastapi import Depends, HTTPException from fastapi.security import OAuth2PasswordBearer @@ -51,3 +52,14 @@ async def get_current_user_id(token: str = Depends(oauth2_schema)) -> str: if user_id is None: raise HTTPException(status_code=422, detail="Could not validate credentials") return user_id + + +async def subscription_validate(token: str = Depends(oauth2_schema), user_crud: UserCrud = Depends()) -> bool: + user_id = decode_access_token(token) + if user_id is None: + raise HTTPException(status_code=422, detail="Could not validate credentials") + async with user_crud: + user = await user_crud.get_user(user_id, True) + if user.is_subscription is False: + raise HTTPException(status_code=422, detail="You need to subscribe.") + return True