feat: implement subscription (#92)

This commit is contained in:
Tianzhou (天舟)
2023-05-18 22:04:27 +08:00
committed by GitHub
parent 666e2091f6
commit b0b6caaffa
43 changed files with 1077 additions and 315 deletions

15
auth.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
import { Subscription } from "@/types";
import "next-auth";
declare module "next-auth" {
interface User {
id: string;
usage: number;
stripeId: number;
subscription: Subscription;
}
interface Session {
user: User;
}
}

View File

@@ -0,0 +1,38 @@
/*
Warnings:
- Added the required column `plan` to the `subscription` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "SubscriptionPlan" AS ENUM ('PRO');
-- AlterTable
ALTER TABLE "subscription" ADD COLUMN "amount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "currency" TEXT NOT NULL DEFAULT '',
ADD COLUMN "description" TEXT NOT NULL DEFAULT '',
ADD COLUMN "email" TEXT NOT NULL DEFAULT '',
ADD COLUMN "plan" "SubscriptionPlan" NOT NULL,
ADD COLUMN "receipt" TEXT NOT NULL DEFAULT '';
-- CreateTable
CREATE TABLE "usage" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"end_user" TEXT NOT NULL DEFAULT '',
"count" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "usage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "usage_created_at_idx" ON "usage"("created_at");
-- CreateIndex
CREATE INDEX "usage_end_user_idx" ON "usage"("end_user");
-- CreateIndex
CREATE INDEX "subscription_user_id_idx" ON "subscription"("user_id");
-- CreateIndex
CREATE INDEX "subscription_email_idx" ON "subscription"("email");

View File

@@ -43,18 +43,38 @@ model Message {
@@map("message")
}
model Usage {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
endUser String @default("") @map("end_user")
count Int @default(0)
@@index([createdAt], map: "usage_created_at_idx")
@@index([endUser], map: "usage_end_user_idx")
@@map("usage")
}
model Subscription {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id])
userId String @default("") @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
status SubscriptionStatus @default(ACTIVE)
startAt DateTime @default(now()) @map("start_at")
expireAt DateTime @default(now()) @map("expire_at")
paymentId String @default("") @map("payment_id")
customerId String @default("") @map("customer_id")
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id])
userId String @default("") @map("user_id")
// Denormalize to avoid join with the user table
email String @default("") @map("email")
createdAt DateTime @default(now()) @map("created_at")
status SubscriptionStatus @default(ACTIVE)
startAt DateTime @default(now()) @map("start_at")
expireAt DateTime @default(now()) @map("expire_at")
paymentId String @default("") @map("payment_id")
customerId String @default("") @map("customer_id")
plan SubscriptionPlan
description String @default("")
amount Int @default(0)
currency String @default("")
receipt String @default("")
@@unique([paymentId])
@@index([userId], map: "subscription_user_id_idx")
@@index([email], map: "subscription_email_idx")
@@map("subscription")
}
@@ -63,6 +83,10 @@ enum SubscriptionStatus {
CANCELED
}
enum SubscriptionPlan {
PRO
}
// NextAuth Prisma Schema Begin
// Below are the auth related prisma schema to support NextAuth.js. In partiular, the email flow
// requires this. https://authjs.dev/reference/adapter/prisma

7
process.d.ts vendored
View File

@@ -9,9 +9,6 @@ declare namespace NodeJS {
OPENAI_API_KEY: string;
// Optional. OpenAI API endpoint. Defaults to https://api.openai.com.
OPENAI_API_ENDPOINT: string;
// Optional. API key to protect the backend API endpoint.
// This needs to be exposed to the frontend and must be prefixed with NEXT_PUBLIC_.
NEXT_PUBLIC_API_KEY: string;
// Optional. NextAuth.js URL. Defaults to the current domain.
NEXTAUTH_URL: string;
// Optional. NextAuth.js secret. Defaults to a randomly generated string.
@@ -33,7 +30,7 @@ declare namespace NodeJS {
STRIPE_API_KEY: string;
// Optional. Stripe webhook secret.
STRIPE_WEBHOOK_SECRET: string;
// Optional. Stripe annual license price id.
STRIPE_ANNUAL_LICENSE_PRICE_ID: string;
// Optional. Stripe price id for Pro plan 1 year subscription.
STRIPE_PRICE_ID_PRO_1_YEAR_SUBSCRIPTION: string;
}
}

View File

@@ -1,10 +1,12 @@
import { signIn, useSession } from "next-auth/react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import SubscriptionHistoryTable from "./SubscriptionHistoryTable";
import { getDateString } from "@/utils";
const AccountView = () => {
const { t } = useTranslation();
const { data: session, status } = useSession();
const { data: session } = useSession();
return (
<>
@@ -17,21 +19,51 @@ const AccountView = () => {
</button>
)}
{session?.user && (
<div className="flex items-center space-x-2">
{session.user.image && (
<img
className="inline-block h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-zinc-700 cursor-pointer"
src={session.user.image}
alt=""
/>
)}
<span>{session.user.email ?? session.user.name}</span>
<Link
href="/api/auth/signout"
className="whitespace-nowrap rounded bg-indigo-600 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
{t("common.sign-out")}
</Link>
<div>
<div className="flex-col space-y-4 sm:space-y-0 sm:flex sm:flex-row justify-between">
<div className="flex items-center space-x-2">
{session.user.image && (
<img
className="inline-block h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-zinc-700 cursor-pointer"
src={session.user.image}
alt=""
/>
)}
<span>{session.user.email ?? session.user.name}</span>
<Link
href="/api/auth/signout"
className="whitespace-nowrap rounded bg-indigo-600 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
{t("common.sign-out")}
</Link>
</div>
<div className="flex text-base font-semibold tracking-tight items-center">
<span
className={`${
session?.user.subscription.plan == "PRO"
? "ring-green-600/2 bg-green-50 text-green-700"
: "ring-gray-600/2 bg-gray-50 text-gray-700"
} rounded-full px-3 py-1 ring-1 ring-inset`}
>
{t(
`setting.plan.${session.user.subscription.plan.toLowerCase()}`
)}
</span>
<div className="ml-4">
{t("setting.plan.n-question-per-month", {
count: session.user.subscription.quota,
})}
</div>
{session.user.subscription.plan === "PRO" && (
<div className="ml-8">
{getDateString(session.user.subscription.startAt)}
{` - `}
{getDateString(session.user.subscription.expireAt)}
</div>
)}
</div>
</div>
<SubscriptionHistoryTable />
</div>
)}
</>

View File

@@ -6,6 +6,7 @@ import {
useConversationStore,
useLayoutStore,
ResponsiveWidth,
useSettingStore,
} from "@/store";
import { Table } from "@/types";
import useLoading from "@/hooks/useLoading";
@@ -14,14 +15,16 @@ import Icon from "./Icon";
import DarkModeSwitch from "./DarkModeSwitch";
import ConversationList from "./Sidebar/ConversationList";
import ConnectionList from "./Sidebar/ConnectionList";
import QuotaWidget from "./QuotaWidget";
import { HasFeature } from "../utils";
import QuotaView from "./QuotaView";
import { hasFeature } from "../utils";
import MultipleSelect from "./kit/MultipleSelect";
import SettingAvatarIcon from "./SettingAvatarIcon";
interface State {}
const ConnectionSidebar = () => {
const { t } = useTranslation();
const settingStore = useSettingStore();
const layoutStore = useLayoutStore();
const connectionStore = useConnectionStore();
const conversationStore = useConversationStore();
@@ -142,8 +145,9 @@ const ConnectionSidebar = () => {
<div className="w-full flex flex-col justify-start items-start">
<ConnectionList />
</div>
<div className="w-full flex flex-col justify-end items-center">
<div className="w-full flex flex-col space-y-2 justify-end items-center">
<DarkModeSwitch />
<SettingAvatarIcon />
</div>
</div>
<div className="relative p-4 pb-0 w-64 h-full overflow-y-auto flex flex-col justify-start items-start bg-gray-100 dark:bg-zinc-700">
@@ -224,19 +228,12 @@ const ConnectionSidebar = () => {
<ConversationList />
</div>
<div className="sticky bottom-0 w-full flex flex-col justify-center bg-gray-100 dark:bg-zinc-700 backdrop-blur bg-opacity-60 pb-4 py-2">
{HasFeature("quota") && (
<div className="mb-4">
<QuotaWidget />
</div>
)}
<a
href="https://discord.gg/z6kakemDjm"
className="text-indigo-600 dark:text-indigo-400 text-sm font-medium flex flex-row justify-center items-center mb-2 hover:underline"
target="_blank"
>
<Icon.BsDiscord className="w-4 h-auto mr-1" />
{t("social.join-discord-channel")}
</a>
{!settingStore.setting.openAIApiConfig?.key &&
hasFeature("quota") && (
<div className="mb-4">
<QuotaView />
</div>
)}
<a
className="dark:hidden"
href="https://www.producthunt.com/posts/sql-chat-2?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-sql&#0045;chat&#0045;2"

View File

@@ -5,7 +5,6 @@ import useDarkMode from "@/hooks/useDarkmode";
import Icon from "../Icon";
import GitHubStarBadge from "../GitHubStarBadge";
import SettingAvatarIcon from "../SettingAvatarIcon";
interface Props {
className?: string;
@@ -60,7 +59,6 @@ const Header = (props: Props) => {
alt=""
/>
</a>
<SettingAvatarIcon />
</div>
</div>
);

View File

@@ -17,6 +17,7 @@ import Icon from "../Icon";
import { CodeBlock } from "../CodeBlock";
import EngineIcon from "../EngineIcon";
import ThreeDotsLoader from "./ThreeDotsLoader";
import { useSession } from "next-auth/react";
interface Props {
message: Message;
@@ -24,6 +25,7 @@ interface Props {
const MessageView = (props: Props) => {
const message = props.message;
const { data: session } = useSession();
const { t } = useTranslation();
const settingStore = useSettingStore();
const userStore = useUserStore();
@@ -84,8 +86,24 @@ const MessageView = (props: Props) => {
{message.content}
</div>
</div>
<div className="w-10 h-10 p-1 border dark:border-zinc-700 rounded-full flex justify-center items-center ml-2 shrink-0">
<Icon.AiOutlineUser className="w-6 h-6" />
<div className="w-10 h-10 border dark:border-zinc-700 rounded-full flex justify-center items-center ml-2 shrink-0">
{session?.user ? (
session.user.image ? (
<img
className="inline-block h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-zinc-700"
src={session.user.image}
alt=""
/>
) : (
<div className="bg-indigo-100 px-3 py-1 rounded-full text-indigo-600 hover:bg-indigo-200 uppercase">
{session.user.name
? session.user.name.charAt(0)
: session.user.email?.charAt(0)}
</div>
)
) : (
<Icon.AiOutlineUser className="w-6 h-6" />
)}
</div>
</>
) : (

View File

@@ -2,7 +2,6 @@ import axios from "axios";
import { head, last } from "lodash-es";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { API_KEY } from "@/env";
import {
getAssistantById,
getPromptGeneratorOfAssistant,
@@ -22,12 +21,15 @@ import ClearConversationButton from "../ClearConversationButton";
import MessageTextarea from "./MessageTextarea";
import DataStorageBanner from "../DataStorageBanner";
import QuotaOverflowBanner from "../QuotaOverflowBanner";
import { useSession } from "next-auth/react";
import getEventEmitter from "@/utils/event-emitter";
// The maximum number of tokens that can be sent to the OpenAI API.
// reference: https://platform.openai.com/docs/api-reference/completions/create#completions/create-max_tokens
const MAX_TOKENS = 4000;
const ConversationView = () => {
const { data: session } = useSession();
const settingStore = useSettingStore();
const layoutStore = useLayoutStore();
const connectionStore = useConnectionStore();
@@ -182,10 +184,12 @@ const ConversationView = () => {
const tableList: string[] = [];
if (currentConversation.selectedTablesName) {
currentConversation.selectedTablesName.forEach((tableName) => {
const table = tables.find((table) => table.name === tableName);
tableList.push(table!.structure);
});
currentConversation.selectedTablesName.forEach(
(tableName: string) => {
const table = tables.find((table) => table.name === tableName);
tableList.push(table!.structure);
}
);
} else {
for (const table of tables) {
tableList.push(table!.structure);
@@ -235,14 +239,21 @@ const ConversationView = () => {
});
const requestHeaders: any = {};
if (API_KEY) {
requestHeaders["Authorization"] = `Bearer ${API_KEY}`;
if (session?.user.id) {
requestHeaders["Authorization"] = `Bearer ${session?.user.id}`;
}
if (settingStore.setting.openAIApiConfig?.key) {
requestHeaders["x-openai-key"] =
settingStore.setting.openAIApiConfig?.key;
}
if (settingStore.setting.openAIApiConfig?.endpoint) {
requestHeaders["x-openai-endpoint"] =
settingStore.setting.openAIApiConfig?.endpoint;
}
const rawRes = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({
messages: formatedMessageList,
openAIApiConfig: settingStore.setting.openAIApiConfig,
}),
headers: requestHeaders,
});
@@ -307,6 +318,9 @@ const ConversationView = () => {
status: "DONE",
});
// Emit usage update event so quota widget can update.
getEventEmitter().emit("usage.update");
// Collect system prompt
// We only collect the db prompt for the system prompt. We do not collect the intermediate
// exchange to save space since those can be derived from the previous record.
@@ -324,10 +338,18 @@ const ConversationView = () => {
usageMessageList.push(assistantMessage);
axios
.post<string[]>("/api/usage", {
conversation: currentConversation,
messages: usageMessageList,
})
.post<string[]>(
"/api/collect",
{
conversation: currentConversation,
messages: usageMessageList,
},
{
headers: session?.user.id
? { Authorization: `Bearer ${session?.user.id}` }
: undefined,
}
)
.catch(() => {
// do nth
});
@@ -341,7 +363,7 @@ const ConversationView = () => {
} relative w-full h-full max-h-full flex flex-col justify-start items-start overflow-y-auto bg-white dark:bg-zinc-800`}
>
<div className="sticky top-0 z-1 bg-white dark:bg-zinc-800 w-full flex flex-col justify-start items-start">
<QuotaOverflowBanner />
{!settingStore.setting.openAIApiConfig?.key && <QuotaOverflowBanner />}
<DataStorageBanner />
<Header className={showHeaderShadow ? "shadow" : ""} />
</div>

View File

@@ -0,0 +1,17 @@
import { useSession } from "next-auth/react";
const PricingView = () => {
const { data: session } = useSession();
return (
<div className="bg-white dark:bg-zinc-800">
<span>Debug View</span>
<div className="pt-2 text-sm">Session Info</div>
<div className="whitespace-pre-wrap mx-auto max-w-7xl lg:flex lg:items-center lg:justify-between">
{JSON.stringify(session, null, "\t")}
</div>
</div>
);
};
export default PricingView;

View File

@@ -4,6 +4,7 @@ import { useDebounce } from "react-use";
import { useSettingStore } from "@/store";
import { OpenAIApiConfig } from "@/types";
import TextField from "./kit/TextField";
import { set } from "lodash-es";
const OpenAIApiConfigView = () => {
const { t } = useTranslation();
@@ -11,6 +12,17 @@ const OpenAIApiConfigView = () => {
const [openAIApiConfig, setOpenAIApiConfig] = useState(
settingStore.setting.openAIApiConfig
);
const [maskKey, setMaskKey] = useState(true);
const maskedKey = (str: string) => {
if (str.length < 7) {
return str;
}
const firstThree = str.slice(0, 3);
const lastFour = str.slice(-4);
const middle = ".".repeat(str.length - 7);
return `${firstThree}${middle}${lastFour}`;
};
useDebounce(
() => {
@@ -25,6 +37,7 @@ const OpenAIApiConfigView = () => {
...openAIApiConfig,
...config,
});
setMaskKey(false);
};
return (
@@ -34,7 +47,9 @@ const OpenAIApiConfigView = () => {
<label className="mb-1">OpenAI API Key</label>
<TextField
placeholder="OpenAI API Key"
value={openAIApiConfig.key}
value={
maskKey ? maskedKey(openAIApiConfig.key) : openAIApiConfig.key
}
onChange={(value) => handleSetOpenAIApiConfig({ key: value })}
/>
</div>

View File

@@ -4,8 +4,6 @@ import { useTranslation } from "react-i18next";
import getStripe from "../utils/get-stripejs";
import { fetchPostJSON } from "../utils/api-helpers";
const includedFeatures = ["Private forum access", "Member resources", "Entry to annual conference", "Official member t-shirt"];
const checkout = async () => {
// Create a Checkout Session.
const response = await fetchPostJSON("/api/checkout_sessions", {});
@@ -35,14 +33,23 @@ const PricingView = () => {
return (
<div className="bg-white dark:bg-zinc-800">
<span className="rounded-full bg-green-50 px-4 py-1.5 text-xl font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
{"🎈 "} {t(`setting.plan.pro`)}
</span>
<div className="mx-auto max-w-7xl p-6 lg:flex lg:items-center lg:justify-between lg:px-8">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">{t("setting.plan.pro-question-per-month")}</h2>
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
{t("setting.plan.n-question-per-month", {
count: 1000,
})}
</h2>
<div className="mt-10 flex items-center gap-x-6 lg:mt-0 lg:flex-shrink-0">
<button
className="rounded-md bg-indigo-600 px-3.5 py-2.5 text-xl font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
onClick={() => (session?.user?.email ? checkout() : signIn())}
>
{session?.user?.email ? t("setting.plan.early-bird-checkout") : t("payment.login-to-buy")}
{session?.user?.email
? t("setting.plan.early-bird-checkout")
: t("payment.sign-in-to-buy")}
</button>
</div>
</div>

View File

@@ -0,0 +1,102 @@
import { signIn, useSession } from "next-auth/react";
import axios from "axios";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { Quota } from "@/types";
import getEventEmitter from "@/utils/event-emitter";
interface Props {
className?: string;
}
const QuotaView = (props: Props) => {
const [quota, setQuota] = useState<Quota>({ current: 0, limit: 0 });
const { t } = useTranslation();
const { data: session } = useSession();
const showSupplyOwnKey = !session || quota.current >= quota.limit;
const expired =
session?.user?.subscription?.expireAt &&
session?.user?.subscription?.expireAt < Date.now();
const showActionButton =
!session || session.user.subscription.plan === "FREE" || expired;
const refreshQuota = async (userId: string) => {
let quota: Quota = { current: 0, limit: 0 };
try {
const { data } = await axios.get("/api/usage", {
headers: { Authorization: `Bearer ${userId}` },
});
quota = data;
} catch (error) {
// do nth
}
setQuota(quota);
};
getEventEmitter().on("usage.update", () => {
if (session?.user.id) {
refreshQuota(session.user.id);
}
});
useEffect(() => {
if (session?.user.id) {
refreshQuota(session.user.id);
}
}, [session]);
return (
<div className="px-4 py-3 space-y-2 rounded-lg border border-indigo-400 flex flex-col dark:text-gray-300 hover:bg-white dark:hover:bg-zinc-800 bg-white dark:bg-zinc-800">
<div className="flex justify-between">
<span className="rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
{session
? t(`setting.plan.${session.user.subscription.plan.toLowerCase()}`)
: t("setting.plan.guest")}
</span>
{!!expired && (
<span className="rounded-full bg-yellow-50 px-2 py-0.5 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-yellow-600/20">
{t("setting.plan.expired")}
</span>
)}
</div>
<div className="flex justify-between pt-1">
<div>{t("common.quota")}</div>
<div
className={
quota.current >= quota.limit ? "text-red-600" : "text-black"
}
>
{quota.current}/{quota.limit}
</div>
</div>
{!!showActionButton &&
(session ? (
<Link
href="/setting"
className="rounded bg-indigo-600 px-2 py-1 text-center text-base font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
{expired ? t("setting.plan.renew") : t("setting.plan.upgrade")}
</Link>
) : (
<button
className="rounded bg-indigo-600 px-2 py-1 text-center text-base font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
onClick={() => signIn()}
>
{t("setting.plan.signup-for-more")}
</button>
))}
{!!showSupplyOwnKey && (
<Link
className="text-center rounded-full underline hover:opacity-80 px-2 py-0.5 text-xs font-medium text-gray-700"
href="/setting"
>
{t("banner.use-my-key")}
</Link>
)}
</div>
);
};
export default QuotaView;

View File

@@ -1,64 +0,0 @@
import { signIn, useSession } from "next-auth/react";
import axios from "axios";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { Quota } from "@/types";
interface Props {
className?: string;
}
const QuotaWidget = (props: Props) => {
const [quota, setQuota] = useState<Quota>({ current: 0, limit: 0 });
const { t } = useTranslation();
const { data: session } = useSession();
useEffect(() => {
const refreshQuota = async () => {
let quota: Quota = { current: 0, limit: 0 };
try {
const { data } = await axios.get("/api/quota", {});
quota = data;
} catch (error) {
// do nth
}
setQuota(quota);
};
refreshQuota();
}, []);
return (
<div className="p-4 space-y-2 rounded-lg border border-indigo-400 flex flex-col dark:text-gray-300 hover:bg-white dark:hover:bg-zinc-800 bg-white dark:bg-zinc-800">
<div className="flex justify-start">
<span className="rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
{t("setting.plan.free")}
</span>
</div>
<div className="flex justify-between">
<div>{t("common.quota")}</div>
<div>
{quota.current}/{quota.limit}
</div>
</div>
{session ? (
<Link
href="/setting"
className="rounded bg-indigo-600 px-2 py-1 text-center text-base font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
{t("setting.plan.upgrade")}
</Link>
) : (
<button
className="rounded bg-indigo-600 px-2 py-1 text-center text-base font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
onClick={() => signIn()}
>
{t("setting.plan.signup-for-more")}
</button>
)}
</div>
);
};
export default QuotaWidget;

View File

@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import Link from "next/link";
import Tooltip from "./kit/Tooltip";
import Icon from "./Icon";
import { HasFeature } from "../utils";
import { hasFeature } from "../utils";
interface Props {}
@@ -14,7 +14,7 @@ const SettingAvatarIcon = (props: Props) => {
return (
<Tooltip title={t("common.setting")} side="right">
<div>
{(!HasFeature("account") || !session) && (
{(!hasFeature("account") || !session) && (
<Link
className=" w-10 h-10 p-1 rounded-full flex flex-row justify-center items-center hover:bg-gray-100 dark:hover:bg-zinc-700"
data-tip={t("common.setting")}
@@ -23,7 +23,7 @@ const SettingAvatarIcon = (props: Props) => {
<Icon.IoMdSettings className="text-gray-600 dark:text-gray-300 w-6 h-auto" />
</Link>
)}
{HasFeature("account") && session?.user && (
{hasFeature("account") && session?.user && (
<Link href="/setting">
{session.user.image ? (
<img
@@ -33,7 +33,9 @@ const SettingAvatarIcon = (props: Props) => {
/>
) : (
<div className="bg-indigo-100 px-3 py-1 rounded-full text-indigo-600 hover:bg-indigo-200 uppercase cursor-pointer">
{session.user.name?.charAt(0)}
{session.user.name
? session.user.name.charAt(0)
: session.user.email?.charAt(0)}
</div>
)}
</Link>

View File

@@ -1,8 +1,10 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { HasFeature } from "../utils";
import { useSession } from "next-auth/react";
import { hasFeature } from "../utils";
import Icon from "./Icon";
import AccountView from "./AccountView";
import DebugView from "./DebugView";
import PricingView from "./PricingView";
import WeChatQRCodeView from "./WeChatQRCodeView";
import ClearDataButton from "./ClearDataButton";
@@ -12,6 +14,7 @@ import OpenAIApiConfigView from "./OpenAIApiConfigView";
const SettingView = () => {
const { t } = useTranslation();
const { data: session } = useSession();
return (
<div className="w-full flex flex-col justify-start items-start space-y-3 pt-4 dark:bg-zinc-800">
@@ -27,13 +30,19 @@ const SettingView = () => {
<WeChatQRCodeView />
</div>
{HasFeature("account") && (
{hasFeature("debug") && (
<div className="w-full border border-gray-200 dark:border-zinc-700 p-4 rounded-lg space-y-2">
<DebugView />
</div>
)}
{hasFeature("account") && (
<div className="w-full border border-gray-200 dark:border-zinc-700 p-4 rounded-lg space-y-2">
<AccountView />
</div>
)}
{HasFeature("payment") && (
{hasFeature("payment") && session?.user?.subscription.plan != "PRO" && (
<div className="w-full border border-gray-200 dark:border-zinc-700 p-4 rounded-lg space-y-2">
<PricingView />
</div>

View File

@@ -0,0 +1,98 @@
import { useEffect, useState } from "react";
import axios from "axios";
import { useSession } from "next-auth/react";
import { SubscriptionPurchase } from "@/types";
import { getCurrencySymbol, getDateString } from "@/utils";
import { t } from "i18next";
const SubscriptionHistoryTable = () => {
const [list, setList] = useState<SubscriptionPurchase[]>([]);
const { data: session } = useSession();
useEffect(() => {
const refreshSubscriptionList = async (userId: string) => {
let list: SubscriptionPurchase[] = [];
try {
const { data } = await axios.get("/api/subscription", {
headers: { Authorization: `Bearer ${userId}` },
});
list = data;
} catch (error) {
// do nth
}
setList(list);
};
if (session?.user.id) {
refreshSubscriptionList(session.user.id);
}
}, [session]);
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="mt-8 flow-root">
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table className="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th
scope="col"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0"
>
{t("common.date")}
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
{t("common.description")}
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
{t("common.amount")}
</th>
<th
scope="col"
className="relative py-3.5 pl-3 pr-4 sm:pr-0"
></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{list.map((subscription) => (
<tr key={subscription.id}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
{getDateString(subscription.createdAt)}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{subscription.description}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{getCurrencySymbol(
subscription.currency.toLocaleUpperCase()
)}
{subscription.amount / 100}
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
<a
href={subscription.receipt}
target="_blank"
className="text-indigo-600 hover:text-indigo-900"
>
{t("setting.billing.view-receipt")}
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
};
export default SubscriptionHistoryTable;

View File

@@ -1,2 +0,0 @@
// API_KEY is using to limit those authorized to use the API and protect the API endpoint.
export const API_KEY = process.env.NEXT_PUBLIC_API_KEY || "";

View File

@@ -13,7 +13,10 @@
"sign-in": "Sign in",
"sign-out": "Sign out",
"back": "Back",
"quota": "Quota"
"quota": "Monthly Quota",
"date": "Date",
"description": "Description",
"amount": "Amount"
},
"conversation": {
"new-chat": "New Chat",
@@ -58,15 +61,19 @@
"self": "Setting",
"general": "General",
"plan": {
"guest": "Guest",
"free": "Free",
"pro": "Pro",
"signup-for-more": "Sign up for more",
"upgrade": "Upgrade",
"pro-question-per-month": "1000 questions / month",
"renew": "Renew",
"expired": "Expired",
"n-question-per-month": "{{count}} questions / month",
"early-bird-checkout": "Early bird discount, 50% off for 1 year"
},
"billing": {
"self": "Billing"
"self": "Billing",
"view-receipt": "View receipt"
},
"account": {
"self": "Account"
@@ -102,6 +109,6 @@
},
"payment": {
"self": "Payment",
"login-to-buy": "Login to buy"
"sign-in-to-buy": "Sign in to buy"
}
}

View File

@@ -9,11 +9,14 @@
"setting": "Configuración",
"copy": "Copiar",
"delete": "Borrar",
"execute": "Ejecutar",
"sign-in": "Iniciar sesión",
"sign-out": "Desconectar",
"back": "Devolver",
"quota": "Cuota",
"execute": "Ejecutar"
"quota": "Cuota Mensual",
"date": "Fecha",
"description": "Descripción",
"amount": "Cantidad"
},
"conversation": {
"new-chat": "Nuevo Chat",
@@ -56,13 +59,19 @@
"self": "Configuración",
"general": "General",
"plan": {
"guest": "Invitada",
"free": "Gratis",
"pro": "Pro",
"signup-for-more": "Regístrese para obtener más",
"upgrade": "Mejora"
"upgrade": "Mejora",
"renew": "Renovar",
"expired": "Expirado",
"n-question-per-month": "{{count}} preguntas / mes",
"early-bird-checkout": "Descuento por reserva anticipada, 50 % de descuento durante 1 año"
},
"billing": {
"self": "Facturación"
"self": "Facturación",
"view-receipt": "Ver recibo"
},
"account": {
"self": "Cuenta"
@@ -92,11 +101,12 @@
"banner": {
"data-storage": "Las configuraciones de conexión y las consultas se almacenan en tu navegador.",
"non-select-sql-warning": "La declaración actual puede no ser SELECT SQL, lo que dará como resultado un esquema de base de datos o un cambio de datos. Asegúrate de saber lo que estás haciendo.",
"product-hunt": "🚀🚀🚀 Acabamos de lanzar en Product Hunt, ¡por favor, vota por nosotros! 🚀🚀🚀"
"product-hunt": "🚀🚀🚀 Acabamos de lanzar en Product Hunt, ¡por favor, vota por nosotros! 🚀🚀🚀",
"quota-overflow": "Cuota excedida. Proporcione su propia clave API de OpenAI.",
"use-my-key": "Usar mi propia clave"
},
"payment": {
"self": "Pago",
"quota-overflow": "Cuota excedida. Proporcione su propia clave API de OpenAI.",
"use-my-key": "Usar mi propia clave"
"sign-in-to-buy": "Inicia sesión para comprar"
}
}

View File

@@ -13,7 +13,10 @@
"sign-in": "登录",
"sign-out": "登出",
"back": "返回",
"quota": "额度"
"quota": "每月额度",
"date": "日期",
"description": "描述",
"amount": "金额"
},
"conversation": {
"new-chat": "新建会话",
@@ -58,15 +61,19 @@
"self": "设置",
"general": "通用",
"plan": {
"guest": "访客",
"free": "免费版",
"pro": "专业版",
"signup-for-more": "注册提升额度",
"signup-for-more": "注册获得额度",
"upgrade": "升级",
"pro-question-per-month": "1000 次提问 / 月",
"renew": "续费",
"expired": "已过期",
"n-question-per-month": "{{count}} 次提问 / 月",
"early-bird-checkout": "早鸟优惠,5 折购买 1 年"
},
"billing": {
"self": "计费"
"self": "计费",
"view-receipt": "查看收据"
},
"account": {
"self": "账户"
@@ -102,6 +109,6 @@
},
"payment": {
"self": "支付",
"login-to-buy": "登陆后购买"
"sign-in-to-buy": "登陆后购买"
}
}

View File

@@ -4,6 +4,7 @@ import GoogleProvider from "next-auth/providers/google";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import EmailProvider from "next-auth/providers/email";
import { PrismaClient } from "@prisma/client";
import { getSubscriptionByEmail } from "../utils/subscription";
const prisma = new PrismaClient();
@@ -27,6 +28,25 @@ export const authOptions: NextAuthOptions = {
clientSecret: process.env.GITHUB_SECRET,
}),
],
cookies: {
sessionToken: {
name: `next-auth.session-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: true,
},
},
},
callbacks: {
async session({ session, user }) {
session.user.id = user.id;
session.user.subscription = await getSubscriptionByEmail(user.email);
session.user.stripeId = user.stripeId;
return session;
},
},
theme: {
brandColor: "#4F46E5",
logo: "/chat-logo.webp",

View File

@@ -1,16 +1,38 @@
import { PrismaClient } from "@prisma/client";
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "./[...nextauth]";
import requestIp from "request-ip";
const prisma = new PrismaClient();
// Returns the login user email or the client IP address
export const getEndUser = async (
req: NextApiRequest,
res: NextApiResponse
): Promise<string> => {
const session = await getServerSession(req, res, authOptions);
if (session?.user?.email) {
return session.user.email;
// Get from server session if available
const serverSession = await getServerSession(req, res, authOptions);
if (serverSession?.user?.email) {
return serverSession.user.email;
}
// Get from session token if available
const token = req.headers.authorization?.substring(7);
if (token) {
const sessionInDb = await prisma.session.findUnique({
where: { sessionToken: token },
});
if (sessionInDb?.userId) {
const user = await prisma.user.findUnique({
where: { id: sessionInDb.userId },
});
if (user?.email) {
return user.email;
}
}
}
// Get from client IP address
return requestIp.getClientIp(req) || "";
};

View File

@@ -1,33 +0,0 @@
import { PrismaClient } from "@prisma/client";
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "./[...nextauth]";
import { getEndUser } from "./end-user";
import { Quota, GUEST_QUOTA, FREE_QUOTA } from "@/types";
const prisma = new PrismaClient();
export const checkQuota = async (
req: NextApiRequest,
res: NextApiResponse
): Promise<boolean> => {
const quota = await getQuota(req, res);
return quota.current <= quota.limit;
};
export const getQuota = async (
req: NextApiRequest,
res: NextApiResponse
): Promise<Quota> => {
const endUser = await getEndUser(req, res);
const session = await getServerSession(req, res, authOptions);
return {
current: await prisma.message.count({
where: {
endUser: endUser,
role: "user",
},
}),
limit: session ? FREE_QUOTA : GUEST_QUOTA,
};
};

View File

@@ -4,9 +4,9 @@ import {
ReconnectInterval,
} from "eventsource-parser";
import { NextRequest } from "next/server";
import { API_KEY } from "@/env";
import { openAIApiEndpoint, openAIApiKey, gpt35 } from "@/utils";
import { openAIApiEndpoint, openAIApiKey, gpt35, hasFeature } from "@/utils";
// Needs Edge for streaming response.
export const config = {
runtime: "edge",
};
@@ -18,28 +18,8 @@ const getApiEndpoint = (apiEndpoint: string) => {
};
const handler = async (req: NextRequest) => {
if (API_KEY) {
const auth = req.headers.get("Authorization");
if (!auth || auth !== `Bearer ${API_KEY}`) {
return new Response(
JSON.stringify({
error: {
message: "Unauthorized.",
},
}),
{
headers: {
"Content-Type": "application/json",
},
status: 401,
}
);
}
}
const reqBody = await req.json();
const openAIApiConfig = reqBody.openAIApiConfig;
const apiKey = openAIApiConfig?.key || openAIApiKey;
const apiKey = req.headers.get("x-openai-key") || openAIApiKey;
if (!apiKey) {
return new Response(
@@ -58,10 +38,69 @@ const handler = async (req: NextRequest) => {
);
}
const apiEndpoint = getApiEndpoint(
openAIApiConfig?.endpoint || openAIApiEndpoint
const useServerKey = !req.headers.get("x-openai-key");
const sessionToken = req.cookies.get("next-auth.session-token")?.value;
const currentUrl = new URL(req.url);
const usageUrl = new URL(
currentUrl.protocol + "//" + currentUrl.host + "/api/usage"
);
const res = await fetch(apiEndpoint, {
const requestHeaders: any = {
Authorization: `Bearer ${sessionToken}`,
};
if (useServerKey) {
if (hasFeature("account") && !sessionToken) {
return new Response(
JSON.stringify({
error: {
message:
"Please sign up to get free quota or supply your own OpenAI key.",
},
}),
{
headers: {
"Content-Type": "application/json",
},
status: 401,
}
);
}
if (hasFeature("quota")) {
const usageRes = await fetch(usageUrl, {
method: "GET",
headers: requestHeaders,
});
if (!usageRes.ok) {
return new Response(usageRes.body, {
status: 500,
statusText: usageRes.statusText,
});
}
const usage = await usageRes.json();
if (usage.current >= usage.limit) {
return new Response(
JSON.stringify({
error: {
message: `You have reached your monthly quota: ${usage.current}/${usage.limit}.`,
},
}),
{
headers: {
"Content-Type": "application/json",
},
status: 401,
}
);
}
}
}
const apiEndpoint = getApiEndpoint(
req.headers.get("x-openai-endpoint") || openAIApiEndpoint
);
const remoteRes = await fetch(apiEndpoint, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
@@ -78,10 +117,10 @@ const handler = async (req: NextRequest) => {
user: req.ip,
}),
});
if (!res.ok) {
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
if (!remoteRes.ok) {
return new Response(remoteRes.body, {
status: remoteRes.status,
statusText: remoteRes.statusText,
});
}
@@ -107,11 +146,20 @@ const handler = async (req: NextRequest) => {
}
};
const parser = createParser(streamParser);
for await (const chunk of res.body as any) {
for await (const chunk of remoteRes.body as any) {
parser.feed(decoder.decode(chunk));
}
},
});
if (useServerKey) {
// Increment usage count
await fetch(usageUrl, {
method: "POST",
headers: requestHeaders,
});
}
return new Response(stream);
};

View File

@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
import { PrismaClient } from "@prisma/client";
import Stripe from "stripe";
import { PlanType } from "@/types";
const stripe = new Stripe(process.env.STRIPE_API_KEY, {
// https://github.com/stripe/stripe-node#configuration
@@ -44,7 +45,7 @@ export default async function handler(
],
line_items: [
{
price: process.env.STRIPE_ANNUAL_LICENSE_PRICE_ID,
price: process.env.STRIPE_PRICE_ID_PRO_1_YEAR_SUBSCRIPTION,
quantity: 1,
},
],
@@ -58,6 +59,8 @@ export default async function handler(
// setup_future_usage: "off_session",
metadata: {
email: session?.user?.email!,
plan: "PRO",
description: "Pro 1 Year License (Early Bird)",
},
},
// Link customer if present otherwise pass email and let Stripe create a new customer.

62
src/pages/api/collect.ts Normal file
View File

@@ -0,0 +1,62 @@
import { PrismaClient } from "@prisma/client";
import { NextApiRequest, NextApiResponse } from "next";
import { Conversation, Message } from "@/types";
import { gpt35 } from "@/utils";
import { getEndUser } from "./auth/end-user";
const prisma = new PrismaClient();
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).json([]);
}
const conversation = req.body.conversation as Conversation;
const messages = req.body.messages as Message[];
try {
const chat = await prisma.chat.findUnique({
where: {
id: conversation.id,
},
});
const endUser = await getEndUser(req, res);
if (chat) {
await prisma.message.createMany({
data: messages.map((message) => ({
chatId: chat.id,
createdAt: new Date(message.createdAt),
endUser: endUser,
role: message.creatorRole,
content: message.content,
upvote: false,
downvote: false,
})),
});
} else {
await prisma.chat.create({
data: {
id: conversation.id,
createdAt: new Date(conversation.createdAt),
model: gpt35,
ctx: {},
messages: {
create: messages.map((message) => ({
createdAt: new Date(message.createdAt),
endUser: endUser,
role: message.creatorRole,
content: message.content,
upvote: false,
downvote: false,
})),
},
},
});
}
} catch (err) {
console.error(err);
}
res.status(200).json(true);
}

View File

@@ -1,11 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getQuota } from "./auth/quota";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json([]);
}
res.status(200).json(await getQuota(req, res));
};
export default handler;

View File

@@ -0,0 +1,16 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getEndUser } from "./auth/end-user";
import { getSubscriptionListByEmail } from "./utils/subscription";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json([]);
}
const endUser = await getEndUser(req, res);
const subscriptionList = await getSubscriptionListByEmail(endUser);
res.status(200).json(subscriptionList);
};
export default handler;

View File

@@ -1,62 +1,38 @@
import { PrismaClient } from "@prisma/client";
import { NextApiRequest, NextApiResponse } from "next";
import { Conversation, Message } from "@/types";
import { gpt35 } from "@/utils";
import { getServerSession } from "next-auth/next";
import { authOptions } from "./auth/[...nextauth]";
import { getSubscriptionByEmail } from "./utils/subscription";
import { addUsage, getCurrentMonthUsage } from "./utils/usage";
import { getEndUser } from "./auth/end-user";
import { Quota } from "@/types";
const prisma = new PrismaClient();
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET" && req.method !== "POST") {
return res.status(405).json([]);
}
const conversation = req.body.conversation as Conversation;
const messages = req.body.messages as Message[];
try {
const chat = await prisma.chat.findUnique({
where: {
id: conversation.id,
},
});
const endUser = await getEndUser(req, res);
if (chat) {
await prisma.message.createMany({
data: messages.map((message) => ({
chatId: chat.id,
createdAt: new Date(message.createdAt),
endUser: endUser,
role: message.creatorRole,
content: message.content,
upvote: false,
downvote: false,
})),
});
} else {
await prisma.chat.create({
data: {
id: conversation.id,
createdAt: new Date(conversation.createdAt),
model: gpt35,
ctx: {},
messages: {
create: messages.map((message) => ({
createdAt: new Date(message.createdAt),
endUser: endUser,
role: message.creatorRole,
content: message.content,
upvote: false,
downvote: false,
})),
},
},
});
}
} catch (err) {
console.error(err);
const endUser = await getEndUser(req, res);
// Get from server session if available
const serverSession = await getServerSession(req, res, authOptions);
let subscripion = serverSession?.user?.subscription;
if (!subscripion) {
subscripion = await getSubscriptionByEmail(endUser);
}
res.status(200).json(true);
}
let usage = 0;
if (req.method === "GET") {
usage = await getCurrentMonthUsage(endUser);
} else if (req.method === "POST") {
usage = await addUsage(endUser);
}
const quota: Quota = {
current: usage,
limit: subscripion.quota,
};
res.status(200).json(quota);
};
export default handler;

View File

@@ -0,0 +1,85 @@
import {
PlanConfig,
PlanType,
Subscription,
SubscriptionPurchase,
} from "@/types";
import { PrismaClient, SubscriptionStatus } from "@prisma/client";
const prisma = new PrismaClient();
export const getSubscriptionByEmail = async (
email: string
): Promise<Subscription> => {
const subscriptions = await prisma.subscription.findMany({
where: { email: email },
orderBy: { expireAt: "desc" },
});
// Return the latest ACTIVE, not-expired subscription if exists.
for (const subscription of subscriptions) {
const result = {
plan: subscription.plan as PlanType,
quota: PlanConfig[subscription.plan].quota,
status: subscription.status,
startAt: subscription.startAt.getTime(),
expireAt: subscription.expireAt.getTime(),
};
if (
subscription.status === SubscriptionStatus.ACTIVE &&
subscription.expireAt.getTime() > Date.now()
) {
return result;
}
}
// Return the latest ACTIVE, expired subscripion if exists.
for (const subscription of subscriptions) {
const result = {
plan: subscription.plan as PlanType,
quota: PlanConfig["FREE"].quota,
status: subscription.status,
startAt: subscription.startAt.getTime(),
expireAt: subscription.expireAt.getTime(),
};
if (subscription.status === SubscriptionStatus.ACTIVE) {
return result;
}
}
// Return a FREE subscription.
return {
plan: "FREE",
quota: PlanConfig["FREE"].quota,
status: SubscriptionStatus.ACTIVE,
startAt: 0,
expireAt: 0,
};
};
export const getSubscriptionListByEmail = async (
email: string
): Promise<SubscriptionPurchase[]> => {
const subscriptions = await prisma.subscription.findMany({
where: { email: email },
orderBy: { expireAt: "desc" },
});
// Return the latest ACTIVE, not-expired subscription if exists.
const result: SubscriptionPurchase[] = [];
for (const subscription of subscriptions) {
result.push({
id: subscription.id,
email: subscription.email,
amount: subscription.amount,
currency: subscription.currency,
receipt: subscription.receipt,
plan: subscription.plan as PlanType,
description: subscription.description,
createdAt: subscription.createdAt.getTime(),
startAt: subscription.startAt.getTime(),
expireAt: subscription.expireAt.getTime(),
});
}
return result;
};

View File

@@ -0,0 +1,63 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export const getCurrentMonthUsage = async (
endUser: string
): Promise<number> => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const aggregations = await prisma.usage.aggregate({
_sum: {
count: true,
},
where: {
endUser: endUser,
createdAt: {
gte: start,
lt: end,
},
},
});
return aggregations._sum.count || 0;
};
// We coerce individual usage to the begining of the day to reduce the usage records.
export const addUsage = async (endUser: string): Promise<number> => {
const now = new Date();
const today = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
);
const usage = await prisma.usage.findFirst({
where: {
endUser: endUser,
createdAt: today,
},
});
let newUsage = 0;
if (usage) {
newUsage = usage.count + 1;
await prisma.usage.update({
where: {
id: usage.id,
},
data: {
count: newUsage,
},
});
} else {
newUsage = 1;
await prisma.usage.create({
data: {
endUser: endUser,
createdAt: today,
count: 1,
},
});
}
return newUsage;
};

View File

@@ -1,9 +1,10 @@
import { buffer } from "micro";
import Cors from "micro-cors";
import { NextApiRequest, NextApiResponse } from "next";
import { PrismaClient, Prisma } from "@prisma/client";
import { PrismaClient, Prisma, SubscriptionPlan } from "@prisma/client";
import Stripe from "stripe";
import { PlanType } from "@/types";
const stripe = new Stripe(process.env.STRIPE_API_KEY, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: "2022-11-15",
@@ -66,14 +67,26 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
});
const today = new Date(new Date().setHours(0, 0, 0, 0));
// Subtract 1 second from the year from now to make it 23:59:59
const yearFromNow = new Date(
new Date(new Date().setHours(0, 0, 0, 0)).setFullYear(
today.getFullYear() + 1
) - 1000
);
const subscription: Prisma.SubscriptionUncheckedCreateInput = {
userId: user.id,
email: paymentIntent.metadata.email,
createdAt: new Date(paymentIntent.created * 1000),
status: "ACTIVE",
startAt: today,
expireAt: new Date(today.setFullYear(today.getFullYear() + 1)),
expireAt: yearFromNow,
paymentId: paymentIntent.id,
customerId: customerId || "",
plan: paymentIntent.metadata.plan as SubscriptionPlan,
description: paymentIntent.metadata.description,
amount: paymentIntent.amount,
currency: paymentIntent.currency,
receipt: paymentIntent.charges.data[0].receipt_url,
};
await prisma.subscription.create({ data: subscription });
} else if (event.type === "payment_intent.payment_failed") {

View File

@@ -4,7 +4,13 @@ import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next";
import Link from "next/link";
import { Dialog, Transition } from "@headlessui/react";
import { ArrowUturnLeftIcon, Bars3Icon, Cog6ToothIcon, XMarkIcon, CreditCardIcon } from "@heroicons/react/24/outline";
import {
ArrowUturnLeftIcon,
Bars3Icon,
Cog6ToothIcon,
XMarkIcon,
CreditCardIcon,
} from "@heroicons/react/24/outline";
import SettingView from "../../components/SettingView";
import StripeCheckPaymentBanner from "../../components/StripeCheckPaymentBanner";
@@ -45,7 +51,11 @@ const SettingPage: NextPage = () => {
<div className="dark:bg-zinc-800">
<Transition.Root show={sidebarOpen} as={Fragment}>
<Dialog as="div" className="relative z-50 lg:hidden" onClose={setSidebarOpen}>
<Dialog
as="div"
className="relative z-50 lg:hidden"
onClose={setSidebarOpen}
>
<Transition.Child
as={Fragment}
enter="transition-opacity ease-linear duration-300"
@@ -79,9 +89,16 @@ const SettingPage: NextPage = () => {
leaveTo="opacity-0"
>
<div className="absolute left-full top-0 flex w-16 justify-center pt-5">
<button type="button" className="-m-2.5 p-2.5" onClick={() => setSidebarOpen(false)}>
<button
type="button"
className="-m-2.5 p-2.5"
onClick={() => setSidebarOpen(false)}
>
<span className="sr-only">Close sidebar</span>
<XMarkIcon className="h-6 w-6 text-white" aria-hidden="true" />
<XMarkIcon
className="h-6 w-6 text-white"
aria-hidden="true"
/>
</button>
</div>
</Transition.Child>
@@ -99,13 +116,17 @@ const SettingPage: NextPage = () => {
<Link
href={item.href}
className={classNames(
item.current ? "bg-gray-50 text-indigo-600" : "text-gray-700 hover:text-indigo-600 hover:bg-gray-50",
item.current
? "bg-gray-50 text-indigo-600"
: "text-gray-700 hover:text-indigo-600 hover:bg-gray-50",
"group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
)}
>
<item.icon
className={classNames(
item.current ? "text-indigo-600" : "text-gray-400 group-hover:text-indigo-600",
item.current
? "text-indigo-600"
: "text-gray-400 group-hover:text-indigo-600",
"h-6 w-6 shrink-0"
)}
aria-hidden="true"
@@ -149,7 +170,9 @@ const SettingPage: NextPage = () => {
>
<item.icon
className={classNames(
item.current ? "!text-indigo-600" : "text-gray-400 group-hover:text-indigo-600",
item.current
? "!text-indigo-600"
: "text-gray-400 group-hover:text-indigo-600",
"h-6 w-6 shrink-0 dark:text-gray-400"
)}
aria-hidden="true"
@@ -166,14 +189,22 @@ const SettingPage: NextPage = () => {
</div>
<div className="sticky top-0 z-40 flex items-center gap-x-6 bg-white dark:bg-zinc-700 px-4 py-4 shadow-sm sm:px-6 lg:hidden">
<button type="button" className="-m-2.5 p-2.5 text-gray-700 dark:text-gray-400 lg:hidden" onClick={() => setSidebarOpen(true)}>
<button
type="button"
className="-m-2.5 p-2.5 text-gray-700 dark:text-gray-400 lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<span className="sr-only">Open sidebar</span>
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
<main className="lg:pl-72">
{router.query.session_id && <StripeCheckPaymentBanner sessionId={router.query.session_id as string} />}
{router.query.session_id && (
<StripeCheckPaymentBanner
sessionId={router.query.session_id as string}
/>
)}
<div className="px-4 sm:px-6 lg:px-8">
<SettingView />
</div>

View File

@@ -6,3 +6,4 @@ export * from "./message";
export * from "./layout";
export * from "./query";
export * from "./setting";
export * from "../utils/event-emitter";

View File

@@ -9,3 +9,4 @@ export * from "./api";
export * from "./connector";
export * from "./assistant";
export * from "./quota";
export * from "./subscription";

View File

@@ -2,8 +2,3 @@ export interface Quota {
current: number;
limit: number;
}
// By month
export const GUEST_QUOTA = 10;
export const FREE_QUOTA = 25;
export const PRO_QUOTA = 1000;

41
src/types/subscription.ts Normal file
View File

@@ -0,0 +1,41 @@
import { SubscriptionPlan, SubscriptionStatus } from "@prisma/client";
export type PlanType = SubscriptionPlan | "GUEST" | "FREE";
// Quota is by month
export const PlanConfig: {
[key: string]: {
quota: number;
};
} = {
GUEST: {
quota: 0,
},
FREE: {
quota: 20,
},
PRO: {
quota: 1000,
},
};
export interface Subscription {
plan: PlanType;
quota: number;
status: SubscriptionStatus;
startAt: number;
expireAt: number;
}
export interface SubscriptionPurchase {
id: string;
email: string;
amount: number;
currency: string;
receipt: string;
plan: PlanType;
description: string;
createdAt: number;
startAt: number;
expireAt: number;
}

View File

@@ -0,0 +1,37 @@
export type EventType = "usage.update";
type Callback = () => void;
class EventEmitter {
private static instance: EventEmitter;
private events: { [eventName: string]: Callback[] } = {};
private constructor() {}
public static getInstance(): EventEmitter {
if (!EventEmitter.instance) {
EventEmitter.instance = new EventEmitter();
}
return EventEmitter.instance;
}
on(eventName: EventType, callback: Callback) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(callback);
}
emit(eventName: EventType) {
const callbacks = this.events[eventName];
if (callbacks) {
callbacks.forEach((callback) => callback());
}
}
}
const getEventEmitter = () => {
return EventEmitter.getInstance();
};
export default getEventEmitter;

View File

@@ -1,8 +1,20 @@
type FeatureType = "account" | "payment" | "quota";
type FeatureType = "debug" | "account" | "payment" | "quota";
export const HasFeature = (feature: FeatureType) => {
if (process.env.NODE_ENV === "development") {
return true;
}
return false;
const matrix: { [key: string]: { [feature: string]: boolean } } = {
development: {
debug: true,
account: true,
payment: true,
quota: true,
},
production: {
debug: true,
account: true,
payment: true,
quota: true,
},
};
export const hasFeature = (feature: FeatureType) => {
return matrix[process.env.NODE_ENV][feature];
};

View File

@@ -4,4 +4,5 @@ export * from "./sql";
export * from "./execution";
export * from "./model";
export * from "./feature";
export * from "./tidb";
export * from "./tidb";
export * from "./misc";

24
src/utils/misc.ts Normal file
View File

@@ -0,0 +1,24 @@
import { timeStamp } from "console";
export const getCurrencySymbol = (currencyCode: string): string => {
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currencyCode,
})
.formatToParts(1)
.find((part) => part.type === "currency").value;
} catch (error) {
console.error(`Invalid currency code: ${currencyCode}`);
return "";
}
};
export const getDateString = (timeStamp = Date.now()): string => {
const dateOptions: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
};
return new Date(timeStamp).toLocaleDateString(undefined, dateOptions);
};

View File

@@ -18,6 +18,12 @@
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "process.d.ts", "**/*.ts", "**/*.tsx"],
"include": [
"next-env.d.ts",
"process.d.ts",
"auth.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": ["node_modules"]
}