formhq: add simple layout, add forms overview, add customers overview, add api endpoints

This commit is contained in:
Matthias Nannt
2022-11-28 18:31:19 +01:00
parent c834cbf8ce
commit 0070f9c69d
36 changed files with 1344 additions and 137 deletions

View File

@@ -14,6 +14,7 @@
"@headlessui/react": "^1.7.4",
"@heroicons/react": "^2.0.13",
"bcryptjs": "^2.4.3",
"clsx": "^1.2.1",
"date-fns": "^2.29.3",
"jsonwebtoken": "^8.5.1",
"next": "^13.0.5",

View File

@@ -5,24 +5,14 @@ import { Bars3Icon, BellIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { signOut, useSession } from "next-auth/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { Fragment } from "react";
import { Fragment, useMemo } from "react";
import { Logo } from "../Logo";
import AvatarPlaceholder from "@/images/avatar-placeholder.png";
import Link from "next/link";
import LoadingSpinner from "../LoadingSpinner";
import clsx from "clsx";
const navigation = [
/* { name: "Forms", href: "#", current: true },
{ name: "Team", href: "#", current: false },
{ name: "Projects", href: "#", current: false },
{ name: "Calendar", href: "#", current: false }, */
];
function classNames(...classes) {
return classes.filter(Boolean).join(" ");
}
export default function ProjectsLayout({ children }) {
export default function ProjectsLayout({ params, children }) {
const router = useRouter();
const userNavigation = [
{
@@ -46,13 +36,14 @@ export default function ProjectsLayout({ children }) {
router.push(`/auth/signin?callbackUrl=${encodeURIComponent(window.location.href)}`);
return <div></div>;
}
return (
<>
<div className="min-h-full">
<div className="h-screen">
<Disclosure as="nav" className="border-b border-gray-200 bg-white">
{({ open }) => (
<>
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mx-auto w-full px-4 sm:px-6 lg:px-8">
<div className="flex h-16 justify-between">
<div className="flex">
<div className="flex flex-shrink-0 items-center">
@@ -60,22 +51,6 @@ export default function ProjectsLayout({ children }) {
<Logo className="block h-8 w-auto" />
</Link>
</div>
<div className="hidden sm:-my-px sm:ml-6 sm:flex sm:space-x-8">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
className={classNames(
item.current
? "border-indigo-500 text-gray-900"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
"inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium"
)}
aria-current={item.current ? "page" : undefined}>
{item.name}
</a>
))}
</div>
</div>
<div className="hidden sm:ml-6 sm:flex sm:items-center">
{/* <button
@@ -113,7 +88,7 @@ export default function ProjectsLayout({ children }) {
{({ active }) => (
<button
onClick={item.onClick}
className={classNames(
className={clsx(
active ? "bg-gray-100" : "",
"flex w-full justify-start px-4 py-2 text-sm text-gray-700"
)}>
@@ -141,23 +116,6 @@ export default function ProjectsLayout({ children }) {
</div>
<Disclosure.Panel className="sm:hidden">
<div className="space-y-1 pt-2 pb-3">
{navigation.map((item) => (
<Disclosure.Button
key={item.name}
as="a"
href={item.href}
className={classNames(
item.current
? "border-indigo-500 bg-indigo-50 text-indigo-700"
: "border-transparent text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-800",
"block border-l-4 py-2 pl-3 pr-4 text-base font-medium"
)}
aria-current={item.current ? "page" : undefined}>
{item.name}
</Disclosure.Button>
))}
</div>
<div className="border-t border-gray-200 pt-4 pb-3">
<div className="flex items-center px-4">
<div className="flex-shrink-0">
@@ -195,11 +153,7 @@ export default function ProjectsLayout({ children }) {
)}
</Disclosure>
<div className="py-10">
<main>
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">{children}</div>
</main>
</div>
<main className="h-full">{children}</main>
</div>
</>
);

View File

@@ -1,33 +1,28 @@
"use client";
import { useMemberships } from "@/lib/memberships";
import { Button } from "@formbricks/ui";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import LoadingSpinner from "../LoadingSpinner";
export default function ProjectsPage() {
const { memberships, isErrorMemberships } = useMemberships();
const router = useRouter();
useEffect(() => {
if (memberships) {
const teamId = memberships[0].teamId;
router.push(`/app/teams/${teamId}/forms`);
}
}, [memberships, router]);
if (isErrorMemberships) {
return <div>Something went wrong...</div>;
}
return (
<header className="mb-8">
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">Dashboard</h1>
<div className="max-w-3xl bg-white shadow sm:rounded-lg">
<div className="mt-8 px-4 py-5 sm:p-6 ">
<h3 className="text-lg font-medium leading-6 text-gray-900">Welcome to Formbricks HQ</h3>
<div className="mt-2 text-sm text-gray-500">
<p>
Formbricks HQ is your backend for Form & Survey Data. Collect data from any form, store and
analyze it in Formbricks HQ or pipe it to third party services.
<br />
<br />
To get started read the docs first or go directly to your account settings to create a new
personal API Key.
</p>
</div>
<div className="mt-5">
<Button variant="secondary" href="/app/me/settings">
Create API Key
</Button>
<Button className="ml-3" href="https://formbricks.com/docs">
Read the Docs
</Button>
</div>
</div>
</div>
</header>
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import LoadingSpinner from "@/app/LoadingSpinner";
import EmptyPageFiller from "@/components/EmptyPageFiller";
import { useCustomers } from "@/lib/customers";
import { useTeam } from "@/lib/teams";
import { convertDateTimeString } from "@/lib/utils";
import { UsersIcon } from "@heroicons/react/24/outline";
export default function FormsPage({ params }) {
const { customers, isLoadingCustomers, isErrorCustomers } = useCustomers(params.teamId);
const { team, isLoadingTeam, isErrorTeam } = useTeam(params.teamId);
if (isLoadingCustomers || isLoadingTeam) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorCustomers || isErrorTeam) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
return (
<div className="mx-auto py-8 sm:px-6 lg:px-8">
<header className="mb-8">
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">
Customers
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
{team.name}
</span>
</h1>
</header>
{customers.length === 0 ? (
<EmptyPageFiller
alertText={"We don't know your customers yet"}
hintText={
"Make a first submission and reference your customer. We will collect all submission from them accross all of your forms and display them here."
}>
<UsersIcon className="stroke-thin mx-auto h-24 w-24 text-slate-300" />
</EmptyPageFiller>
) : (
<div className="mt-8 flex flex-col">
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">
Id
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
created At
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
# submissions
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span className="sr-only">View</span>
</th>
</tr>
</thead>
<tbody className="bg-white">
{customers.map((customer, customerIdx) => (
<tr key={customer.email} className={customerIdx % 2 === 0 ? undefined : "bg-gray-50"}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{customer.id}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{convertDateTimeString(customer.createdAt)}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{customer._count?.submissions}
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<a href="#" className="text-brand-dark hover:text-brand-light">
View<span className="sr-only">, {customer.name}</span>
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import EmptyPageFiller from "@/components/EmptyPageFiller";
import { deleteForm, useForms } from "@/lib/forms";
import { Menu, Transition } from "@headlessui/react";
import { CommandLineIcon, DocumentPlusIcon, PlusIcon, SquaresPlusIcon } from "@heroicons/react/24/outline";
import { EllipsisHorizontalIcon, TrashIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import Link from "next/link";
import { Fragment, useState } from "react";
import NewFormModal from "./NewFormModal";
export default function FormsList({ teamId }) {
const { forms, mutateForms } = useForms(teamId);
const [openNewFormModal, setOpenNewFormModal] = useState(false);
const newForm = async () => {
setOpenNewFormModal(true);
};
const deleteFormAction = async (form, formIdx) => {
try {
await deleteForm(teamId, form.id);
// remove locally
const updatedForms = JSON.parse(JSON.stringify(forms));
updatedForms.splice(formIdx, 1);
mutateForms(updatedForms);
} catch (error) {
console.error(error);
}
};
return (
<>
<div className="h-full">
{forms &&
(forms.length === 0 ? (
<div className="mt-5 text-center">
<EmptyPageFiller
onClick={() => newForm()}
alertText="You don't have any forms yet."
hintText="Start by creating a form."
buttonText="create form"
borderStyles="border-4 border-dotted border-red"
hasButton={true}>
<DocumentPlusIcon className="stroke-thin mx-auto h-24 w-24 text-slate-300" />
</EmptyPageFiller>
</div>
) : (
<ul className="grid grid-cols-2 place-content-stretch gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8 ">
<button onClick={() => newForm()}>
<li className="col-span-1 h-56">
<div className="from-brand-light to-brand-dark delay-50 flex h-full items-center justify-center overflow-hidden rounded-md bg-gradient-to-b font-light text-white shadow transition ease-in-out hover:scale-105">
<div className="px-4 py-8 sm:p-14">
<PlusIcon className="stroke-thin mx-auto h-14 w-14" />
create form
</div>
</div>
</li>
</button>
{forms
.sort((a, b) => b.updatedAt - a.updatedAt)
.map((form, formIdx) => (
<li key={form.id} className="relative col-span-1 h-56">
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
<div className="p-6">
<p className="line-clamp-3 text-lg">{form.label}</p>
</div>
<Link
href={`/app/teams/${teamId}/forms/${form.id}`}
className="absolute h-full w-full"></Link>
<div className="divide-y divide-slate-100 ">
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
<p className="text-xs text-slate-400 ">{form._count?.submissions} submissions</p>
<Menu as="div" className="relative z-10 inline-block text-left">
{({ open }) => (
<>
<div>
<Menu.Button className="text-red -m-2 flex items-center rounded-full p-2">
<span className="sr-only">Open options</span>
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button>
</div>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
<Menu.Items
static
className="absolute left-0 mt-2 w-56 origin-top-right rounded-sm bg-white px-1 shadow-lg">
<div className="py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => {
if (
confirm(
"Are you sure you want to delete this form? This also deletes all submissions that are captures with this form. This action cannot be undone."
)
) {
deleteFormAction(form, formIdx);
}
}}
className={clsx(
active
? "text-ui-black rounded-sm bg-slate-100"
: "text-slate-800",
"flex w-full px-4 py-2 text-sm"
)}>
<TrashIcon
className="mr-3 h-5 w-5 text-slate-800"
aria-hidden="true"
/>
<span>Delete Form</span>
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
</div>
</div>
</div>
</li>
))}
</ul>
))}
</div>
<NewFormModal open={openNewFormModal} setOpen={setOpenNewFormModal} teamId={teamId} />
</>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { Dialog, RadioGroup, Transition } from "@headlessui/react";
import { CheckCircleIcon, XMarkIcon } from "@heroicons/react/24/solid";
import { useRouter } from "next/navigation";
import { Fragment, useState } from "react";
import { BsPlus } from "react-icons/bs";
import { createForm } from "@/lib/forms";
import clsx from "clsx";
import { Button } from "@formbricks/ui";
type FormOnboardingModalProps = {
open: boolean;
setOpen: (v: boolean) => void;
teamId: number;
};
export default function NewFormModal({ open, setOpen, teamId }: FormOnboardingModalProps) {
const router = useRouter();
const [label, setLabel] = useState("");
const createFormAction = async (e) => {
e.preventDefault();
const form = await createForm(teamId, {
label,
});
router.push(`app/teams/${teamId}/forms/${form.id}/`);
};
return (
<Transition.Root show={open} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={setOpen}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-30 backdrop-blur-md transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative transform rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
<div className="absolute top-0 right-0 hidden pt-4 pr-4 sm:block">
<button
type="button"
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-0 focus:ring-offset-2"
onClick={() => setOpen(false)}>
<span className="sr-only">Close</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
<div className="flex flex-row justify-between">
<h2 className="flex-none p-2 text-xl font-bold text-slate-800">Create new form</h2>
</div>
<form
onSubmit={(e) => createFormAction(e)}
className="inline-block w-full transform overflow-hidden p-2 text-left align-bottom transition-all sm:align-middle">
<div>
<label htmlFor="email" className="text-sm font-light text-slate-800">
Name your form
</label>
<div className="mt-2">
<input
type="text"
name="label"
className="focus:border-brand focus:ring-brand block w-full rounded-md border-gray-300 shadow-sm sm:text-sm"
placeholder="e.g. Customer Research Survey"
value={label}
onChange={(e) => setLabel(e.target.value)}
autoFocus
required
/>
</div>
</div>
<div className="mt-5 sm:mt-6">
<Button type="submit" className="w-full justify-center">
create form
<BsPlus className="ml-1 h-6 w-6"></BsPlus>
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import LoadingSpinner from "@/app/LoadingSpinner";
import { useForm } from "@/lib/forms";
import { useTeam } from "@/lib/teams";
export default function FormsPage({ params }) {
const { form, isLoadingForm, isErrorForm } = useForm(params.formId, params.teamId);
const { team, isLoadingTeam, isErrorTeam } = useTeam(params.teamId);
if (isLoadingForm || isLoadingTeam) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorForm || isErrorTeam) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
return (
<div className="mx-auto py-8 sm:px-6 lg:px-8">
<header className="mb-8">
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">
{form.label}
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
{team.name}
</span>
</h1>
</header>
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import LoadingSpinner from "@/app/LoadingSpinner";
import { useForms } from "@/lib/forms";
import { useTeam } from "@/lib/teams";
import FormsList from "./FormsList";
export default function FormsPage({ params }) {
const { forms, isLoadingForms, isErrorForms } = useForms(params.teamId);
const { team, isLoadingTeam, isErrorTeam } = useTeam(params.teamId);
if (isLoadingForms || isLoadingTeam) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorForms || isErrorTeam) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
return (
<div className="mx-auto py-8 sm:px-6 lg:px-8">
<header className="mb-8">
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">
Forms
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
{team.name}
</span>
</h1>
</header>
<FormsList teamId={params.teamId} />
</div>
);
}

View File

@@ -0,0 +1,175 @@
"use client";
import { Dialog, Transition } from "@headlessui/react";
import { Cog8ToothIcon, RectangleStackIcon, UsersIcon, XMarkIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Fragment, useMemo, useState } from "react";
function classNames(...classes) {
return classes.filter(Boolean).join(" ");
}
export default function Example({ children, params }) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const pathname = usePathname();
const sidebarNavigation = useMemo(
() => [
{
name: "Forms",
href: `/app/teams/${params.teamId}/forms`,
icon: RectangleStackIcon,
current: pathname.includes("/form"),
},
{
name: "Customers",
href: `/app/teams/${params.teamId}/customers`,
icon: UsersIcon,
current: pathname.includes("/customers"),
},
{
name: "Settings",
href: `/app/teams/${params.teamId}/settings`,
icon: Cog8ToothIcon,
current: pathname.includes("/settings"),
},
],
[params, pathname]
);
return (
<>
{/*
This example requires updating your template:
```
<html class="h-full bg-gray-50">
<body class="h-full overflow-hidden">
```
*/}
<div className="flex h-full">
{/* Narrow sidebar */}
<div className="bg-brand-dark hidden w-28 overflow-y-auto bg-gradient-to-r md:block">
<div className="flex w-full flex-col items-center py-6">
<div className="w-full flex-1 space-y-1 px-2">
{sidebarNavigation.map((item) => (
<Link
key={item.name}
href={item.href}
className={classNames(
item.current
? "bg-brand-light text-white"
: "hover:bg-brand-light text-teal-100 hover:text-white",
"group flex w-full flex-col items-center rounded-md p-3 text-xs font-medium"
)}
aria-current={item.current ? "page" : undefined}>
<item.icon
className={classNames(
item.current ? "text-white" : "text-teal-300 group-hover:text-white",
"h-6 w-6"
)}
aria-hidden="true"
/>
<span className="mt-2">{item.name}</span>
</Link>
))}
</div>
</div>
</div>
{/* Mobile menu */}
<Transition.Root show={mobileMenuOpen} as={Fragment}>
<Dialog as="div" className="relative z-20 md:hidden" onClose={setMobileMenuOpen}>
<Transition.Child
as={Fragment}
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" />
</Transition.Child>
<div className="fixed inset-0 z-40 flex">
<Transition.Child
as={Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full">
<Dialog.Panel className="bg-brand-light relative flex w-full max-w-xs flex-1 flex-col pt-5 pb-4">
<Transition.Child
as={Fragment}
enter="ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="absolute top-1 right-0 -mr-14 p-1">
<button
type="button"
className="flex h-12 w-12 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-white"
onClick={() => setMobileMenuOpen(false)}>
<XMarkIcon className="h-6 w-6 text-white" aria-hidden="true" />
<span className="sr-only">Close sidebar</span>
</button>
</div>
</Transition.Child>
<div className="flex flex-shrink-0 items-center px-4">
<img
className="h-8 w-auto"
src="https://tailwindui.com/img/logos/mark.svg?color=white"
alt="Your Company"
/>
</div>
<div className="mt-5 h-0 flex-1 overflow-y-auto px-2">
<nav className="flex h-full flex-col">
<div className="space-y-1">
{sidebarNavigation.map((item) => (
<a
key={item.name}
href={item.href}
className={classNames(
item.current
? "bg-brand-dark text-white"
: "hover:bg-brand-dark text-teal-100 hover:text-white",
"group flex items-center rounded-md py-2 px-3 text-sm font-medium"
)}
aria-current={item.current ? "page" : undefined}>
<item.icon
className={classNames(
item.current ? "text-white" : "text-teal-300 group-hover:text-white",
"mr-3 h-6 w-6"
)}
aria-hidden="true"
/>
<span>{item.name}</span>
</a>
))}
</div>
</nav>
</div>
</Dialog.Panel>
</Transition.Child>
<div className="w-14 flex-shrink-0" aria-hidden="true">
{/* Dummy element to force sidebar to shrink to fit close icon */}
</div>
</div>
</Dialog>
</Transition.Root>
{/* Content area */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Main content */}
<div className="flex flex-1 items-stretch overflow-hidden">
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import LoadingSpinner from "@/app/LoadingSpinner";
import { useForms } from "@/lib/forms";
import { useTeam } from "@/lib/teams";
export default function FormsPage({ params }) {
const { forms, isLoadingForms, isErrorForms } = useForms(params.teamId);
const { team, isLoadingTeam, isErrorTeam } = useTeam(params.teamId);
if (isLoadingForms || isLoadingTeam) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorForms || isErrorTeam) {
return <div>Error loading ressources. Maybe you don&lsquo;t have enough access rights</div>;
}
return (
<div className="mx-auto py-8 sm:px-6 lg:px-8">
<header className="mb-8">
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">
Settings
<span className="text-brand-dark ml-4 inline-flex items-center rounded-md border border-teal-100 bg-teal-50 px-2.5 py-0.5 text-sm font-medium">
{team.name}
</span>
</h1>
</header>
</div>
);
}

View File

@@ -40,7 +40,7 @@ export const PasswordResetForm = ({}) => {
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
Email address
</label>
<div className="mt-1">

View File

@@ -40,7 +40,7 @@ export const ResetPasswordForm = ({ token }) => {
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
New password
</label>
<div className="mt-1">

View File

@@ -9,7 +9,7 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
const router = useRouter();
if (session) {
router.push("/projects");
router.push("/app");
}
return (
<div className="isolate bg-white">

View File

@@ -18,7 +18,7 @@ export default function SignInPage() {
};
return (
<>
<div className="bg-ui-gray-light flex min-h-screen">
<div className="flex min-h-screen bg-slate-100">
<div className="mx-auto flex flex-1 flex-col justify-center px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
{error && (
<div className="absolute top-10 rounded-md bg-red-50 p-4">
@@ -49,7 +49,7 @@ export default function SignInPage() {
action="/api/auth/callback/credentials"
className="space-y-6">
<div>
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
Email address
</label>
<div className="mt-1">
@@ -59,12 +59,12 @@ export default function SignInPage() {
type="email"
autoComplete="email"
required
className="placeholder-ui-gray-medium border-ui-gray-medium ph-no-capture block w-full appearance-none rounded-md border px-3 py-2 shadow-sm focus:border-red-500 focus:outline-none focus:ring-red-500 sm:text-sm"
className="ph-no-capture block w-full appearance-none rounded-md border border-slate-300 px-3 py-2 placeholder-slate-300 shadow-sm focus:border-red-500 focus:outline-none focus:ring-red-500 sm:text-sm"
/>
</div>
</div>
<div>
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
Password
</label>
<div className="mt-1">
@@ -74,7 +74,7 @@ export default function SignInPage() {
type="password"
autoComplete="current-password"
required
className="placeholder-ui-gray-medium border-ui-gray-medium ph-no-capture block w-full appearance-none rounded-md border px-3 py-2 shadow-sm focus:border-red-500 focus:outline-none focus:ring-red-500 sm:text-sm"
className="ph-no-capture block w-full appearance-none rounded-md border border-slate-300 px-3 py-2 placeholder-slate-300 shadow-sm focus:border-red-500 focus:outline-none focus:ring-red-500 sm:text-sm"
/>
</div>
</div>

View File

@@ -10,7 +10,7 @@ export const SigninForm = ({ callbackUrl, error }) => {
const handleSubmit = async (e) => {
e.preventDefault();
await signIn("credentials", {
callbackUrl: callbackUrl || "/projects",
callbackUrl: callbackUrl || "/app",
email: e.target.elements.email.value,
password: e.target.elements.password.value,
});
@@ -35,7 +35,7 @@ export const SigninForm = ({ callbackUrl, error }) => {
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
Email address
</label>
<div className="mt-1">
@@ -50,7 +50,7 @@ export const SigninForm = ({ callbackUrl, error }) => {
</div>
</div>
<div>
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
Password
</label>
<div className="mt-1">

View File

@@ -50,7 +50,7 @@ export const SignupForm = () => {
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="text-ui-gray-dark block text-sm font-medium">
<label htmlFor="name" className="block text-sm font-medium text-slate-800">
Full Name
</label>
<div className="mt-1">
@@ -65,7 +65,7 @@ export const SignupForm = () => {
</div>
</div>
<div>
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
Email address
</label>
<div className="mt-1">
@@ -80,7 +80,7 @@ export const SignupForm = () => {
</div>
</div>
<div>
<label htmlFor="email" className="text-ui-gray-dark block text-sm font-medium">
<label htmlFor="email" className="block text-sm font-medium text-slate-800">
Password
</label>
<div className="mt-1">

View File

@@ -0,0 +1,39 @@
"use client";
import { Button } from "@formbricks/ui";
import React from "react";
interface Props {
children: React.ReactNode;
onClick?: () => void;
alertText: string;
hintText: string;
buttonText?: string;
borderStyles?: string;
hasButton?: boolean;
}
const EmptyPageFiller: React.FC<Props> = ({
children,
onClick = () => {},
alertText,
hintText,
buttonText,
borderStyles,
hasButton = false,
}) => {
return (
<div className={`mx-auto mt-8 rounded-lg border border-slate-200 p-8 text-center ` + borderStyles}>
{children}
<h3 className="mt-5 text-base font-bold text-slate-400">{alertText}</h3>
<p className="mt-1 text-xs font-light text-slate-400">{hintText}</p>
{hasButton && (
<div className="mt-6">
<Button onClick={onClick}>{buttonText}</Button>
</div>
)}
</div>
);
};
export default EmptyPageFiller;

View File

@@ -1,5 +1,8 @@
import { createHash } from "crypto";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { prisma } from "@formbricks/database";
import { NextApiRequest, NextApiResponse } from "next";
import { unstable_getServerSession } from "next-auth";
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
@@ -23,3 +26,23 @@ export const hasOwnership = async (model, session, id) => {
return false;
}
};
export const getSessionOrUser = async (req: NextApiRequest, res: NextApiResponse) => {
// check for session (browser usage)
let session = await unstable_getServerSession(req, res, authOptions);
if (session && "user" in session) return session.user;
// check for api key
if (req.headers["x-api-key"]) {
const apiKey = await prisma.apiKey.findUnique({
where: {
hashedKey: hashApiKey(req.headers["x-api-key"].toString()),
},
include: {
user: true,
},
});
if (apiKey && apiKey.user) {
return apiKey.user;
}
}
};

View File

@@ -0,0 +1,35 @@
import useSWR from "swr";
import { fetcher } from "./utils";
export const useCustomers = (teamId: number) => {
const { data, error, mutate } = useSWR(`/api/teams/${teamId}/customers`, fetcher);
return {
customers: data,
isLoadingCustomers: !error && !data,
isErrorCustomers: error,
mutateCustomers: mutate,
};
};
export const useCustomer = (id: string, teamId: number) => {
const { data, error, mutate } = useSWR(`/api/teams/${teamId}/customers/${id}`, fetcher);
return {
customer: data,
isLoadingCustomer: !error && !data,
isErrorCustomer: error,
mutateCustomer: mutate,
};
};
export const deleteCustomer = async (id: string, teamId: number) => {
try {
await fetch(`/api/teams/${teamId}/customers/${id}`, {
method: "DELETE",
});
} catch (error) {
console.error(error);
throw Error(`deleteCustomer: unable to delete customer: ${error.message}`);
}
};

102
apps/hq/src/lib/forms.ts Normal file
View File

@@ -0,0 +1,102 @@
import useSWR from "swr";
import { fetcher } from "./utils";
export const useForms = (teamId) => {
const { data, error, mutate } = useSWR(`/api/teams/${teamId}/forms`, fetcher);
return {
forms: data,
isLoadingForms: !error && !data,
isErrorForms: error,
mutateForms: mutate,
};
};
export const useForm = (id: string, teamId: string) => {
const { data, error, mutate } = useSWR(`/api/teams/${teamId}/forms/${id}`, fetcher);
return {
form: data,
isLoadingForm: !error && !data,
isErrorForm: error,
mutateForm: mutate,
};
};
export const persistForm = async (form) => {
try {
await fetch(`/api/forms/${form.id}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
} catch (error) {
console.error(error);
}
};
export const createForm = async (teamId, form = {}) => {
try {
const res = await fetch(`/api/teams/${teamId}/forms`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
return await res.json();
} catch (error) {
console.error(error);
throw Error(`createForm: unable to create form: ${error.message}`);
}
};
export const deleteForm = async (teamId, formId) => {
try {
await fetch(`/api/teams/${teamId}/forms/${formId}`, {
method: "DELETE",
});
} catch (error) {
console.error(error);
throw Error(`deleteForm: unable to delete form: ${error.message}`);
}
};
export const getFormElementFieldSetter = (
form: any,
mutateForm: (any, boolean?) => void,
pageId: string,
elementId: string
) => {
return (input, field, parentField = "") =>
setFormElementField(form, mutateForm, pageId, elementId, input, field, parentField);
};
export const setFormElementField = (
form: any,
mutateForm: (any, boolean?) => void,
pageId: string,
elementId: string,
input: string | number,
field: string,
parentField: string = ""
) => {
const updatedForm = JSON.parse(JSON.stringify(form));
const elementIdx = getFormPage(updatedForm, pageId).elements.findIndex((e) => e.id === elementId);
if (typeof elementIdx === "undefined") {
throw Error(`setFormElementField: unable to find element with id ${elementId}`);
}
if (parentField !== "") {
getFormPage(updatedForm, pageId).elements[elementIdx][parentField][field] = input;
} else {
getFormPage(updatedForm, pageId).elements[elementIdx][field] = input;
}
mutateForm(updatedForm, false);
return updatedForm;
};
export const getFormPage = (form, pageId) => {
const page = form.pages.find((p) => p.id === pageId);
if (typeof page === "undefined") {
throw Error(`getFormPage: unable to find page with id ${pageId}`);
}
return page;
};

View File

@@ -0,0 +1,13 @@
import useSWR from "swr";
import { fetcher } from "./utils";
export const useMemberships = () => {
const { data, error, mutate } = useSWR(`/api/memberships`, fetcher);
return {
memberships: data,
isLoadingMemberships: !error && !data,
isErrorMemberships: error,
mutateMemberships: mutate,
};
};

13
apps/hq/src/lib/teams.ts Normal file
View File

@@ -0,0 +1,13 @@
import useSWR from "swr";
import { fetcher } from "./utils";
export const useTeam = (id: string) => {
const { data, error, mutate } = useSWR(`/api/teams/${id}/`, fetcher);
return {
team: data,
isLoadingTeam: !error && !data,
isErrorTeam: error,
mutateTeam: mutate,
};
};

View File

@@ -0,0 +1,59 @@
import { getSessionOrUser } from "@/lib/apiHelper";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const formId = parseInt(req.query.formId.toString());
if (isNaN(formId)) {
return res.status(400).json({ message: "Invalid formId" });
}
// POST/capture/forms/[formId]/submissions
// Create a new form submission
// Required fields in body: -
// Optional fields in body: customerId, data
else if (req.method === "POST") {
const submission = req.body;
const event: any = {
data: {
data: submission.data,
form: { connect: { id: formId } },
},
};
if (submission.customerId) {
// get team
const form = await prisma.form.findUnique({
where: { id: formId },
select: {
teamId: true,
},
});
// create or link customer
event.data.customer = {
connectOrCreate: {
where: {
id_teamId: {
id: submission.customerId,
teamId: form.teamId,
},
},
create: {
id: submission.customerId,
team: { connect: { id: form.teamId } },
},
},
};
}
// create form in db
const result = await prisma.submission.create(event);
res.json(result);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -0,0 +1,30 @@
import { getSessionOrUser } from "@/lib/apiHelper";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// Check Authentication
const session = await getSessionOrUser(req, res);
if (!session) {
return res.status(401).json({ message: "Not authenticated" });
}
// GET /api/teams
// Get all of my teams
if (req.method === "GET") {
const memberships = await prisma.membership.findMany({
where: {
user: { email: session.email },
},
include: {
team: true,
},
});
return res.json(memberships);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -0,0 +1,53 @@
import { getSessionOrUser } from "@/lib/apiHelper";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// Check Authentication
const user: any = await getSessionOrUser(req, res);
if (!user) {
return res.status(401).json({ message: "Not authenticated" });
}
const teamId = parseInt(req.query.teamId.toString());
if (isNaN(teamId)) {
return res.status(400).json({ message: "Invalid teamId" });
}
// check team permission
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: {
userId: user.id,
teamId,
},
},
});
if (membership === null) {
return res.status(403).json({ message: "You don't have access to this team or this team doesn't exist" });
}
// GET /api/teams[teamId]/customers
// Get all customers of a specific team
if (req.method === "GET") {
const forms = await prisma.customer.findMany({
where: {
team: {
id: teamId,
},
},
include: {
_count: {
select: { submissions: true },
},
},
});
return res.json(forms);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -0,0 +1,60 @@
import { getSessionOrUser } from "@/lib/apiHelper";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// Check Authentication
const user: any = await getSessionOrUser(req, res);
if (!user) {
return res.status(401).json({ message: "Not authenticated" });
}
const teamId = parseInt(req.query.teamId.toString());
if (isNaN(teamId)) {
return res.status(400).json({ message: "Invalid teamId" });
}
const formId = parseInt(req.query.formId.toString());
if (isNaN(formId)) {
return res.status(400).json({ message: "Invalid formId" });
}
// check team permission
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: {
userId: user.id,
teamId,
},
},
});
if (membership === null) {
return res.status(403).json({ message: "You don't have access to this team or this team doesn't exist" });
}
// GET /api/teams[teamId]/forms/[formId]
// Get a specific team
if (req.method === "GET") {
const forms = await prisma.form.findUnique({
where: {
id: formId,
},
});
return res.json(forms);
}
// Delete /api/teams[teamId]/forms/[formId]
// Deletes a single form
else if (req.method === "DELETE") {
const prismaRes = await prisma.form.delete({
where: { id: formId },
});
return res.json(prismaRes);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -0,0 +1,54 @@
import { getSessionOrUser } from "@/lib/apiHelper";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// Check Authentication
const user: any = await getSessionOrUser(req, res);
if (!user) {
return res.status(401).json({ message: "Not authenticated" });
}
const teamId = parseInt(req.query.teamId.toString());
if (isNaN(teamId)) {
return res.status(400).json({ message: "Invalid teamId" });
}
const formId = parseInt(req.query.formId.toString());
if (isNaN(formId)) {
return res.status(400).json({ message: "Invalid formId" });
}
// check team permission
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: {
userId: user.id,
teamId,
},
},
});
if (membership === null) {
return res.status(403).json({ message: "You don't have access to this team or this team doesn't exist" });
}
// GET /api/teams[teamId]/forms/[formId]/submissions
// Get submissions
if (req.method === "GET") {
// get submission
const submissions = await prisma.submission.findMany({
where: {
form: {
id: formId,
},
},
});
return res.json(submissions);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -0,0 +1,70 @@
import { getSessionOrUser } from "@/lib/apiHelper";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// Check Authentication
const user: any = await getSessionOrUser(req, res);
if (!user) {
return res.status(401).json({ message: "Not authenticated" });
}
const teamId = parseInt(req.query.teamId.toString());
if (isNaN(teamId)) {
return res.status(400).json({ message: "Invalid teamId" });
}
// check team permission
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: {
userId: user.id,
teamId,
},
},
});
if (membership === null) {
return res.status(403).json({ message: "You don't have access to this team or this team doesn't exist" });
}
// GET /api/teams[teamId]/forms
// Get a specific team
if (req.method === "GET") {
const forms = await prisma.form.findMany({
where: {
team: {
id: teamId,
},
},
include: {
_count: {
select: { submissions: true },
},
},
});
return res.json(forms);
}
// POST /api/teams[teamId]/forms
// Create a new form
// Required fields in body: -
// Optional fields in body: label, schema
else if (req.method === "POST") {
const form = req.body;
// create form in db
const result = await prisma.form.create({
data: {
...form,
team: { connect: { id: teamId } },
},
});
res.json(result);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -0,0 +1,48 @@
import { getSessionOrUser, hashApiKey } from "@/lib/apiHelper";
import { prisma } from "@formbricks/database";
import { randomBytes } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { unstable_getServerSession } from "next-auth";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// Check Authentication
const user: any = await getSessionOrUser(req, res);
if (!user) {
return res.status(401).json({ message: "Not authenticated" });
}
const teamId = parseInt(req.query.teamId.toString());
if (isNaN(teamId)) {
return res.status(400).json({ message: "Invalid teamId" });
}
// GET /api/teams[teamId]
// Get a specific team
if (req.method === "GET") {
// check if membership exists
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: {
userId: user.id,
teamId,
},
},
});
if (membership === null) {
return res
.status(403)
.json({ message: "You don't have access to this team or this team doesn't exist" });
}
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
});
return res.json(team);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -4,7 +4,6 @@ import { prisma } from "@formbricks/database";
import { randomBytes } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { unstable_getServerSession } from "next-auth";
import { getSession } from "next-auth/react";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// Check Authentication
@@ -16,7 +15,6 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
// GET /api/users/[userId]/api-keys/
// Gets all ApiKeys of a user
if (req.method === "GET") {
const session = await getSession({ req });
const apiKeys = await prisma.apiKey.findMany({
where: {
user: { email: session.user.email },
@@ -32,8 +30,6 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
const apiKey = req.body;
const key = randomBytes(16).toString("hex");
const session = await getSession({ req });
// create form in database
const result = await prisma.apiKey.create({
data: {

View File

@@ -0,0 +1,37 @@
import { getSessionOrUser, hashApiKey } from "@/lib/apiHelper";
import { prisma } from "@formbricks/database";
import { randomBytes } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { unstable_getServerSession } from "next-auth";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// Check Authentication
const session = await getSessionOrUser(req, res);
if (!session) {
return res.status(401).json({ message: "Not authenticated" });
}
// GET /api/users/me
// Get the current user
if (req.method === "GET") {
const user = await prisma.user.findUnique({
where: {
email: session.email,
},
select: {
id: true,
createdAt: true,
updatedAt: true,
email: true,
name: true,
identityProvider: true,
},
});
return res.json(user);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -1,3 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
[data-nextjs-scroll-focus-boundary] {
display: contents;
}
}

View File

@@ -1,4 +1,4 @@
const base = require("@formbricks/tailwind-config/tailwind.config");
const base = require("../../packages/tailwind-config/tailwind.config");
/** @type {import('tailwindcss').Config} */
module.exports = {

View File

@@ -23,13 +23,13 @@ CREATE TABLE "Pipeline" (
-- CreateTable
CREATE TABLE "Customer" (
"id" SERIAL NOT NULL,
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"teamId" INTEGER NOT NULL,
"data" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id")
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id","teamId")
);
-- CreateTable
@@ -37,6 +37,7 @@ CREATE TABLE "Form" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"label" TEXT NOT NULL,
"teamId" INTEGER NOT NULL,
"schema" JSONB NOT NULL DEFAULT '{}',
@@ -48,9 +49,9 @@ CREATE TABLE "Submission" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"teamId" INTEGER,
"formId" INTEGER NOT NULL,
"customerId" INTEGER NOT NULL,
"customerId" TEXT,
"teamId" INTEGER,
"data" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "Submission_pkey" PRIMARY KEY ("id")
@@ -109,7 +110,7 @@ CREATE TABLE "Account" (
);
-- CreateTable
CREATE TABLE "users" (
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
@@ -120,7 +121,7 @@ CREATE TABLE "users" (
"identityProvider" "IdentityProvider" NOT NULL DEFAULT 'EMAIL',
"identityProviderAccountId" TEXT,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
@@ -133,4 +134,4 @@ CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey");
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

View File

@@ -30,48 +30,49 @@ model Pipeline {
}
model Customer {
id Int @id @default(autoincrement())
id String
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int
Submissions Submission[]
submissions Submission[]
data Json @default("{}")
@@id([id, teamId])
}
model Form {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int
schema Json @default("{}")
submission Submission[]
Pipeline Pipeline[]
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
label String
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int
schema Json @default("{}")
submissions Submission[]
pipelines Pipeline[]
}
model Submission {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
Team Team? @relation(fields: [teamId], references: [id])
teamId Int?
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
formId Int
customer Customer @relation(fields: [customerId], references: [id])
customerId Int
data Json @default("{}")
customer Customer? @relation(fields: [customerId, teamId], references: [id, teamId])
customerId String?
teamId Int?
data Json @default("{}")
}
model Team {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
members Membership[]
forms Form[]
Customer Customer[]
Submission Submission[]
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
members Membership[]
forms Form[]
customers Customer[]
}
enum MembershipRole {
@@ -138,7 +139,5 @@ model User {
identityProviderAccountId String?
teams Membership[]
accounts Account[]
ApiKey ApiKey[]
@@map(name: "users")
apiKeys ApiKey[]
}

2
pnpm-lock.yaml generated
View File

@@ -128,6 +128,7 @@ importers:
'@types/react-dom': ^18.0.9
autoprefixer: ^10.4.13
bcryptjs: ^2.4.3
clsx: ^1.2.1
date-fns: ^2.29.3
eslint: ^8.28.0
eslint-config-formbricks: workspace:*
@@ -149,6 +150,7 @@ importers:
'@headlessui/react': 1.7.4_biqbaboplfbrettd7655fr4n2y
'@heroicons/react': 2.0.13_react@18.2.0
bcryptjs: 2.4.3
clsx: 1.2.1
date-fns: 2.29.3
jsonwebtoken: 8.5.1
next: 13.0.5_biqbaboplfbrettd7655fr4n2y