├── .yarnrc.yml ├── .npmrc ├── server ├── tsconfig.json ├── api │ ├── inngest.ts │ ├── me.ts │ ├── workspaces │ │ ├── index.get.ts │ │ ├── index.post.ts │ │ └── [workspaceId] │ │ │ └── index.get.ts │ ├── forms │ │ ├── [formId] │ │ │ ├── submissions │ │ │ │ ├── index.delete.ts │ │ │ │ ├── index.get.ts │ │ │ │ └── csv.get.ts │ │ │ ├── index.get.ts │ │ │ └── index.put.ts │ │ └── index.post.ts │ ├── stripe │ │ ├── portal.post.ts │ │ ├── checkout.post.ts │ │ └── webhook.post.ts │ └── auth │ │ └── [...].ts ├── middleware │ └── prisma.ts └── routes │ └── f │ └── [formId].ts ├── public ├── logo.png ├── react.png ├── favicon.png ├── feature1.png ├── featureHero.png ├── html.html ├── vue.svg └── html.svg ├── .yarn └── install-state.gz ├── tsconfig.json ├── inngest ├── client.ts └── functions │ ├── index.ts │ ├── webhook.ts │ ├── respondentEmailNotification.ts │ ├── selfEmailNotification.ts │ └── formBackgroundJob.ts ├── app.config.ts ├── prisma ├── migrations │ ├── 20240109041157_emails │ │ └── migration.sql │ ├── 20230911045224_add_closed_to_form_table │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20230916124324_add_spam_flag_to_submission_table │ │ └── migration.sql │ ├── 20230911051446_add_self_email_notification_to_form_table │ │ └── migration.sql │ ├── 20230914130042_add_webhook_to_form_table │ │ └── migration.sql │ ├── 20230913123110_add_settings_attributes_to_form_table │ │ └── migration.sql │ ├── 20230920124937_add_stripe_attributes │ │ └── migration.sql │ ├── 20230909054307_create_feature_tables │ │ └── migration.sql │ └── 20230908150845_create_user_account │ │ └── migration.sql └── schema.prisma ├── types ├── next-auth.d.ts └── index.ts ├── pages ├── settings │ ├── billing.vue │ └── index.vue ├── index.vue ├── test.vue ├── thank-you.vue ├── forms │ └── [formId].vue ├── dashboard.vue ├── workspaces │ └── [workspaceId].vue ├── contact.vue ├── pricing.vue ├── refund.vue ├── terms.vue └── privacy.vue ├── layouts ├── open.vue └── default.vue ├── utils ├── index.ts └── stripe.ts ├── .gitignore ├── app.vue ├── .env.example ├── components ├── Sidebar │ ├── FormRow.vue │ ├── WorkspaceRow.vue │ └── List.vue ├── TheSideBar.vue ├── Lp │ ├── FormCode.vue │ ├── Cta.vue │ ├── TopNav.vue │ ├── FeatureSection.vue │ ├── Footer.vue │ └── HeroSection.vue ├── icon │ └── Google.vue ├── Form │ ├── CsvDownload.vue │ ├── Integrations.vue │ ├── Setup.vue │ ├── Submissions.vue │ └── Settings.vue ├── workspace │ └── CreateForm.vue ├── TheProfileMenu.vue ├── CreateFormModal.vue └── Settings │ └── Billing.vue ├── docker-compose.yml ├── package.json ├── nuxt.config.ts ├── README.md └── store └── workspace.ts /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naveennaidu/OpenformStack/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naveennaidu/OpenformStack/HEAD/public/react.png -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naveennaidu/OpenformStack/HEAD/public/favicon.png -------------------------------------------------------------------------------- /public/feature1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naveennaidu/OpenformStack/HEAD/public/feature1.png -------------------------------------------------------------------------------- /.yarn/install-state.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naveennaidu/OpenformStack/HEAD/.yarn/install-state.gz -------------------------------------------------------------------------------- /public/featureHero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naveennaidu/OpenformStack/HEAD/public/featureHero.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /inngest/client.ts: -------------------------------------------------------------------------------- 1 | import { Inngest } from "inngest"; 2 | 3 | export const inngest = new Inngest({ id: "OpenformStack" }); 4 | -------------------------------------------------------------------------------- /inngest/functions/index.ts: -------------------------------------------------------------------------------- 1 | import formBackgroundJob from "./formBackgroundJob"; 2 | 3 | export default [formBackgroundJob]; 4 | -------------------------------------------------------------------------------- /app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | ui: { 3 | primary: "orange", 4 | gray: "neutral", 5 | }, 6 | }); 7 | -------------------------------------------------------------------------------- /prisma/migrations/20240109041157_emails/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Form" ADD COLUMN "selfEmails" TEXT[]; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230911045224_add_closed_to_form_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Form" ADD COLUMN "closed" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/migrations/20230916124324_add_spam_flag_to_submission_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Submission" ADD COLUMN "isSpam" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230911051446_add_self_email_notification_to_form_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Form" ADD COLUMN "selfEmailNotification" BOOLEAN NOT NULL DEFAULT true; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230914130042_add_webhook_to_form_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Form" ADD COLUMN "webhookEnabled" BOOLEAN NOT NULL DEFAULT false, 3 | ADD COLUMN "webhookUrl" TEXT; 4 | -------------------------------------------------------------------------------- /types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | 3 | declare module "next-auth" { 4 | interface Session { 5 | user: { 6 | id: string; 7 | } & DefaultSession["user"]; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /pages/settings/billing.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /server/api/inngest.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "inngest/nuxt"; 2 | import { inngest } from "@/inngest/client"; 3 | import functions from "@/inngest/functions"; 4 | 5 | export default defineEventHandler(serve({ client: inngest, functions })); 6 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | import type { Form, Workspace } from "@prisma/client"; 2 | 3 | export type WorkspaceWithForms = Workspace & { forms: Form[] }; 4 | 5 | export interface Pagination { 6 | skip: number; 7 | take: number; 8 | total: number; 9 | } 10 | -------------------------------------------------------------------------------- /layouts/open.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/html.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 |
7 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | export function isEmail(email: string) { 2 | const emailRegex = 3 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 4 | return emailRegex.test(email); 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | .vercel 26 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /server/middleware/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | let prisma: PrismaClient; 4 | 5 | declare module "h3" { 6 | interface H3EventContext { 7 | prisma: PrismaClient; 8 | } 9 | } 10 | 11 | export default eventHandler((event) => { 12 | if (!prisma) { 13 | prisma = new PrismaClient(); 14 | } 15 | event.context.prisma = prisma; 16 | }); 17 | -------------------------------------------------------------------------------- /prisma/migrations/20230913123110_add_settings_attributes_to_form_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Form" ADD COLUMN "customRedirect" BOOLEAN NOT NULL DEFAULT false, 3 | ADD COLUMN "customRedirectUrl" TEXT, 4 | ADD COLUMN "fromName" TEXT, 5 | ADD COLUMN "message" TEXT, 6 | ADD COLUMN "respondentEmailNotification" BOOLEAN NOT NULL DEFAULT false, 7 | ADD COLUMN "subject" TEXT; 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DEFAULT_DATABASE_HOSTNAME=localhost 2 | DEFAULT_DATABASE_USER=postgres 3 | DEFAULT_DATABASE_PASSWORD=postgres 4 | DEFAULT_DATABASE_PORT=5436 5 | DEFAULT_DATABASE_DB=headless_forms 6 | 7 | DATABASE_URL="postgres://postgres:postgres@localhost:5436/headless_forms?schema=public" 8 | 9 | # AUTH 10 | GOOGLE_CLIENT_ID= 11 | GOOGLE_CLIENT_SECRET= 12 | API_ROUTE_SECRET= 13 | 14 | # EMAIL 15 | RESEND_API_KEY= 16 | FROM_MAIL= 17 | 18 | BASE_URL= -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /public/vue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /server/api/me.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "#auth"; 2 | 3 | export default defineEventHandler(async (event) => { 4 | const session = await getServerSession(event); 5 | 6 | if (!session) { 7 | throw createError({ statusMessage: "Unauthenticated", statusCode: 403 }); 8 | } 9 | 10 | const { prisma } = event.context; 11 | 12 | const user = await prisma.user.findUnique({ 13 | where: { 14 | id: session.user.id, 15 | }, 16 | include: { 17 | Subscription: true, 18 | }, 19 | }); 20 | 21 | return { user }; 22 | }); 23 | -------------------------------------------------------------------------------- /components/Sidebar/FormRow.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | 24 | 29 | -------------------------------------------------------------------------------- /inngest/functions/webhook.ts: -------------------------------------------------------------------------------- 1 | import { inngest } from "@/inngest/client"; 2 | 3 | export default inngest.createFunction( 4 | { id: "Webhook" }, 5 | { event: "app/webhook" }, 6 | async ({ event }) => { 7 | const webhookUrl = event.data.webhookUrl; 8 | const formName = event.data.formName; 9 | const submission = event.data.submission; 10 | 11 | await $fetch(webhookUrl, { 12 | method: "POST", 13 | body: JSON.stringify({ 14 | formName, 15 | submission, 16 | }), 17 | }); 18 | 19 | return { event }; 20 | } 21 | ); 22 | -------------------------------------------------------------------------------- /components/TheSideBar.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /components/Lp/FormCode.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | # For local development, only database is running 4 | # 5 | # docker-compose up -d 6 | # 7 | 8 | services: 9 | default_database: 10 | restart: unless-stopped 11 | image: postgres:latest 12 | volumes: 13 | - default_database_data:/var/lib/postgresql/data 14 | environment: 15 | - POSTGRES_DB=${DEFAULT_DATABASE_DB} 16 | - POSTGRES_USER=${DEFAULT_DATABASE_USER} 17 | - POSTGRES_PASSWORD=${DEFAULT_DATABASE_PASSWORD} 18 | env_file: 19 | - .env 20 | ports: 21 | - "${DEFAULT_DATABASE_PORT}:5432" 22 | 23 | volumes: 24 | default_database_data: 25 | -------------------------------------------------------------------------------- /pages/test.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 24 | -------------------------------------------------------------------------------- /public/html.svg: -------------------------------------------------------------------------------- 1 | 2 | HTML5 Logo Badge 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /server/api/workspaces/index.get.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "#auth"; 2 | 3 | export default defineEventHandler(async (event) => { 4 | const session = await getServerSession(event); 5 | 6 | if (!session) { 7 | throw createError({ statusMessage: "Unauthenticated", statusCode: 403 }); 8 | } 9 | 10 | const { prisma } = event.context; 11 | 12 | const workspaces = await prisma.workspace.findMany({ 13 | where: { 14 | users: { 15 | some: { 16 | id: session.user.id, 17 | }, 18 | }, 19 | }, 20 | include: { 21 | forms: true, 22 | }, 23 | }); 24 | 25 | return { workspaces }; 26 | }); 27 | -------------------------------------------------------------------------------- /components/icon/Google.vue: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /server/api/workspaces/index.post.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "#auth"; 2 | import { z, parseBodyAs } from "@sidebase/nuxt-parse"; 3 | 4 | const bodySchema = z.object({ 5 | name: z.string(), 6 | }); 7 | 8 | export default defineEventHandler(async (event) => { 9 | const session = await getServerSession(event); 10 | const { name } = await parseBodyAs(event, bodySchema); 11 | 12 | if (!session) { 13 | throw createError({ statusMessage: "Unauthenticated", statusCode: 403 }); 14 | } 15 | 16 | const { prisma } = event.context; 17 | 18 | const workspace = await prisma.workspace.create({ 19 | data: { 20 | name, 21 | users: { 22 | connect: { 23 | id: session.user.id, 24 | }, 25 | }, 26 | }, 27 | }); 28 | 29 | return { workspace }; 30 | }); 31 | -------------------------------------------------------------------------------- /inngest/functions/respondentEmailNotification.ts: -------------------------------------------------------------------------------- 1 | import { inngest } from "@/inngest/client"; 2 | import { Resend } from "resend"; 3 | 4 | const resend = new Resend(useRuntimeConfig().RESEND_API_KEY); 5 | 6 | export default inngest.createFunction( 7 | { id: "Respondent Email Notification" }, 8 | { event: "app/email.respondentNotification" }, 9 | async ({ event }) => { 10 | const fromName = event.data.fromName; 11 | const to = event.data.to; 12 | const subject = event.data.subject; 13 | const message = event.data.message; 14 | const reply_to = event.data.replyTo; 15 | 16 | await resend.emails.send({ 17 | from: `${fromName} <${useRuntimeConfig().public.FROM_MAIL}>`, 18 | to, 19 | subject: subject, 20 | text: message, 21 | reply_to, 22 | }); 23 | return { event }; 24 | } 25 | ); 26 | -------------------------------------------------------------------------------- /pages/thank-you.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /components/Sidebar/WorkspaceRow.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /server/api/forms/[formId]/submissions/index.delete.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "#auth"; 2 | import { z, parseBodyAs } from "@sidebase/nuxt-parse"; 3 | 4 | const bodySchema = z.object({ 5 | ids: z.array(z.string()), 6 | }); 7 | 8 | export default defineEventHandler(async (event) => { 9 | const session = await getServerSession(event); 10 | const { ids } = await parseBodyAs(event, bodySchema); 11 | 12 | if (!session) { 13 | throw createError({ statusMessage: "Unauthenticated", statusCode: 403 }); 14 | } 15 | 16 | const { prisma } = event.context; 17 | 18 | const data = await prisma.submission.deleteMany({ 19 | where: { 20 | id: { 21 | in: ids, 22 | }, 23 | form: { 24 | workspace: { 25 | users: { 26 | some: { 27 | id: session.user.id, 28 | }, 29 | }, 30 | }, 31 | }, 32 | }, 33 | }); 34 | return { data }; 35 | }); 36 | -------------------------------------------------------------------------------- /server/api/forms/[formId]/index.get.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "#auth"; 2 | import { z, parseParamsAs } from "@sidebase/nuxt-parse"; 3 | 4 | const paramSchema = z.object({ 5 | formId: z.string(), 6 | }); 7 | 8 | export default defineEventHandler(async (event) => { 9 | const session = await getServerSession(event); 10 | const { formId } = parseParamsAs(event, paramSchema); 11 | 12 | if (!session) { 13 | throw createError({ statusMessage: "Unauthenticated", statusCode: 403 }); 14 | } 15 | 16 | const { prisma } = event.context; 17 | 18 | const form = await prisma.form.findUnique({ 19 | where: { 20 | id: formId, 21 | workspace: { 22 | users: { 23 | some: { 24 | id: session.user.id, 25 | }, 26 | }, 27 | }, 28 | }, 29 | }); 30 | if (!form) { 31 | throw createError({ statusMessage: "Not Found", statusCode: 404 }); 32 | } 33 | return { form }; 34 | }); 35 | -------------------------------------------------------------------------------- /inngest/functions/selfEmailNotification.ts: -------------------------------------------------------------------------------- 1 | import { inngest } from "@/inngest/client"; 2 | import { Resend } from "resend"; 3 | import { isEmail } from "~/utils"; 4 | 5 | const resend = new Resend(useRuntimeConfig().RESEND_API_KEY); 6 | 7 | export default inngest.createFunction( 8 | { id: "Self Email Notification" }, 9 | { event: "app/email.selfNotification" }, 10 | async ({ event }) => { 11 | const userEmails = event.data.emails; 12 | const formName = event.data.formName; 13 | const body = event.data.body; 14 | 15 | await resend.emails.send({ 16 | from: `OpenformStack <${useRuntimeConfig().public.FROM_MAIL}>`, 17 | to: userEmails, 18 | subject: `New submission for ${formName}`, 19 | html: ` 20 | ${Object.entries(body) 21 | .map(([key, value]) => `
${key}: ${value}
`) 22 | .join("")} 23 | `, 24 | ...(body.email && isEmail(body.email) ? { reply_to: body.email } : {}), 25 | }); 26 | return { event }; 27 | } 28 | ); 29 | -------------------------------------------------------------------------------- /components/Form/CsvDownload.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /pages/settings/index.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /server/api/stripe/portal.post.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "#auth"; 2 | import { createOrRetrieveCustomer, stripe } from "@/utils/stripe"; 3 | 4 | export default defineEventHandler(async (event) => { 5 | const session = await getServerSession(event); 6 | if (!session) { 7 | throw createError({ statusMessage: "Unauthenticated", statusCode: 401 }); 8 | } 9 | 10 | const { prisma } = event.context; 11 | 12 | const user = await createOrRetrieveCustomer({ 13 | prisma, 14 | userId: session.user.id, 15 | }); 16 | 17 | if (!user) { 18 | throw createError({ statusMessage: "User not found", statusCode: 404 }); 19 | } 20 | 21 | try { 22 | if (!user.stripeCustomerId) return; 23 | const { url } = await stripe.billingPortal.sessions.create({ 24 | customer: user.stripeCustomerId, 25 | return_url: `${useRuntimeConfig().public.BASE_URL}/settings/billing`, 26 | }); 27 | return { url }; 28 | } catch (error: any) { 29 | console.log(error); 30 | throw createError({ 31 | statusMessage: "Stripe Portal error", 32 | message: error.message, 33 | statusCode: 500, 34 | }); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /prisma/migrations/20230920124937_add_stripe_attributes/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[stripeCustomerId]` on the table `User` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "User" ADD COLUMN "stripeCustomerId" TEXT; 9 | 10 | -- CreateTable 11 | CREATE TABLE "Subscription" ( 12 | "id" TEXT NOT NULL, 13 | "status" TEXT, 14 | "priceId" TEXT, 15 | "quantity" INTEGER, 16 | "cancelAtPeriodEnd" BOOLEAN, 17 | "created" TEXT NOT NULL, 18 | "currentPeriodStart" TEXT NOT NULL, 19 | "currentPeriodEnd" TEXT NOT NULL, 20 | "endedAt" TEXT, 21 | "cancelAt" TEXT, 22 | "canceledAt" TEXT, 23 | "trialStart" TEXT, 24 | "trialEnd" TEXT, 25 | "userId" TEXT NOT NULL, 26 | 27 | CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") 28 | ); 29 | 30 | -- CreateIndex 31 | CREATE UNIQUE INDEX "User_stripeCustomerId_key" ON "User"("stripeCustomerId"); 32 | 33 | -- AddForeignKey 34 | ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 35 | -------------------------------------------------------------------------------- /server/api/workspaces/[workspaceId]/index.get.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "#auth"; 2 | import { z, parseParamsAs } from "@sidebase/nuxt-parse"; 3 | 4 | const paramSchema = z.object({ 5 | workspaceId: z.string(), 6 | }); 7 | 8 | export default defineEventHandler(async (event) => { 9 | const session = await getServerSession(event); 10 | const { workspaceId } = parseParamsAs(event, paramSchema); 11 | 12 | if (!session) { 13 | throw createError({ statusMessage: "Unauthenticated", statusCode: 403 }); 14 | } 15 | 16 | const { prisma } = event.context; 17 | 18 | const workspace = await prisma.workspace.findUnique({ 19 | where: { 20 | id: workspaceId, 21 | users: { 22 | some: { 23 | id: session.user.id, 24 | }, 25 | }, 26 | }, 27 | include: { 28 | forms: { 29 | include: { 30 | submissions: { 31 | select: { 32 | id: true, 33 | createdAt: true, 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }); 40 | if (!workspace) { 41 | throw createError({ statusMessage: "Not Found", statusCode: 404 }); 42 | } 43 | return { workspace }; 44 | }); 45 | -------------------------------------------------------------------------------- /server/api/forms/index.post.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "#auth"; 2 | import { z, parseBodyAs } from "@sidebase/nuxt-parse"; 3 | 4 | const bodySchema = z.object({ 5 | name: z.string(), 6 | workspaceId: z.string(), 7 | }); 8 | 9 | export default defineEventHandler(async (event) => { 10 | const session = await getServerSession(event); 11 | const { name, workspaceId } = await parseBodyAs(event, bodySchema); 12 | 13 | if (!session) { 14 | throw createError({ statusMessage: "Unauthenticated", statusCode: 403 }); 15 | } 16 | 17 | const { prisma } = event.context; 18 | 19 | // Check if user is a member of the workspace 20 | const workspace = await prisma.workspace.findUnique({ 21 | where: { 22 | id: workspaceId, 23 | users: { 24 | some: { 25 | id: session.user.id, 26 | }, 27 | }, 28 | }, 29 | }); 30 | 31 | if (!workspace) { 32 | throw createError({ 33 | statusMessage: "User is not a member of the workspace", 34 | statusCode: 422, 35 | }); 36 | } 37 | 38 | const form = await prisma.form.create({ 39 | data: { 40 | name, 41 | workspace: { 42 | connect: { 43 | id: workspaceId, 44 | }, 45 | }, 46 | }, 47 | }); 48 | 49 | return { form }; 50 | }); 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "scripts": { 5 | "build": "nuxt build", 6 | "dev": " nuxt dev --dotenv .env.development.local", 7 | "generate": "nuxt generate", 8 | "preview": "nuxt preview", 9 | "vercel-build": "prisma generate && prisma migrate deploy && nuxt build", 10 | "postinstall": "nuxt prepare", 11 | "prisma:migrate:dev": "npx prisma migrate dev", 12 | "prisma:migrate:generate": "npx prisma generate", 13 | "prisma:reset": "npx prisma migrate reset", 14 | "prisma:studio": "npx prisma studio" 15 | }, 16 | "devDependencies": { 17 | "@nuxt/devtools": "latest", 18 | "@nuxt/ui": "^2.11.1", 19 | "@sidebase/nuxt-auth": "^0.6.3", 20 | "dayjs-nuxt": "^2.1.9", 21 | "nuxt": "^3.8.2", 22 | "prisma": "^5.7.0" 23 | }, 24 | "dependencies": { 25 | "@heroicons/vue": "^2.0.18", 26 | "@next-auth/prisma-adapter": "^1.0.7", 27 | "@pinia/nuxt": "^0.5.1", 28 | "@prisma/client": "5.7.0", 29 | "@productdevbook/chatwoot": "^1.3.0", 30 | "@sidebase/nuxt-parse": "^0.3.0", 31 | "@stripe/stripe-js": "^2.2.1", 32 | "@upstash/ratelimit": "^1.0.0", 33 | "@vercel/kv": "^1.0.1", 34 | "csv-stringify": "^6.4.5", 35 | "highlight.js": "^11.9.0", 36 | "inngest": "^3.7.1", 37 | "next-auth": "4.21.1", 38 | "pinia": "^2.1.7", 39 | "resend": "^2.0.0", 40 | "stripe": "^14.8.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /server/api/auth/[...].ts: -------------------------------------------------------------------------------- 1 | import { NuxtAuthHandler } from "#auth"; 2 | import GoogleProvider from "next-auth/providers/google"; 3 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 4 | import { PrismaClient } from "@prisma/client"; 5 | 6 | const runtimeConfig = useRuntimeConfig(); 7 | const prisma = new PrismaClient(); 8 | 9 | export default NuxtAuthHandler({ 10 | adapter: PrismaAdapter(prisma), 11 | secret: useRuntimeConfig().API_ROUTE_SECRET, 12 | session: { 13 | strategy: "jwt", 14 | }, 15 | debug: process.env.NODE_ENV === "development", 16 | providers: [ 17 | // @ts-expect-error 18 | GoogleProvider.default({ 19 | clientId: useRuntimeConfig().public.GOOGLE_CLIENT_ID, 20 | clientSecret: runtimeConfig.GOOGLE_CLIENT_SECRET, 21 | }), 22 | ], 23 | callbacks: { 24 | session({ session, token }) { 25 | session.user.id = token.id; 26 | return session; 27 | }, 28 | jwt({ token, account, user }) { 29 | if (account) { 30 | token.accessToken = account.access_token; 31 | token.id = user?.id; 32 | } 33 | return token; 34 | }, 35 | }, 36 | events: { 37 | createUser: async (message) => { 38 | await prisma.workspace.create({ 39 | data: { 40 | name: "My workspace", 41 | users: { 42 | connect: { 43 | id: message.user.id, 44 | }, 45 | }, 46 | }, 47 | }); 48 | }, 49 | }, 50 | }); 51 | -------------------------------------------------------------------------------- /components/workspace/CreateForm.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /server/api/forms/[formId]/index.put.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "#auth"; 2 | import z from "zod"; 3 | 4 | const bodySchema = z.object({ 5 | name: z.string().optional(), 6 | closed: z.boolean().optional(), 7 | selfEmailNotification: z.boolean().optional(), 8 | selfEmails: z.array(z.string()).optional(), 9 | respondentEmailNotification: z.boolean().optional(), 10 | fromName: z.string().nullable().optional(), 11 | subject: z.string().nullable().optional(), 12 | message: z.string().nullable().optional(), 13 | customRedirect: z.boolean().optional(), 14 | customRedirectUrl: z.string().optional(), 15 | webhookEnabled: z.boolean().optional(), 16 | webhookUrl: z.string().optional(), 17 | }); 18 | 19 | const paramSchema = z.object({ 20 | formId: z.string(), 21 | }); 22 | 23 | export default defineEventHandler(async (event) => { 24 | const session = await getServerSession(event); 25 | const body = await readValidatedBody(event, bodySchema.parse); 26 | const { formId } = await getValidatedRouterParams(event, paramSchema.parse); 27 | 28 | if (!session) { 29 | throw createError({ statusMessage: "Unauthenticated", statusCode: 403 }); 30 | } 31 | 32 | const { prisma } = event.context; 33 | 34 | const form = await prisma.form.update({ 35 | where: { 36 | id: formId, 37 | workspace: { 38 | users: { 39 | some: { 40 | id: session.user.id, 41 | }, 42 | }, 43 | }, 44 | }, 45 | data: body, 46 | }); 47 | 48 | if (!form) { 49 | throw createError({ statusMessage: "Not Found", statusCode: 404 }); 50 | } 51 | 52 | return { form }; 53 | }); 54 | -------------------------------------------------------------------------------- /components/TheProfileMenu.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /components/CreateFormModal.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /components/Sidebar/List.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /components/Lp/Cta.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /server/api/forms/[formId]/submissions/index.get.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "#auth"; 2 | import { z, parseParamsAs, parseQueryAs } from "@sidebase/nuxt-parse"; 3 | 4 | const paramSchema = z.object({ 5 | formId: z.string(), 6 | }); 7 | 8 | const querySchema = z.object({ 9 | skip: z.string().optional().default("0").transform(Number), 10 | take: z.string().optional().default("10").transform(Number), 11 | isSpam: z 12 | .string() 13 | .optional() 14 | .default("false") 15 | .transform((v) => v === "true"), 16 | }); 17 | 18 | export default defineEventHandler(async (event) => { 19 | const session = await getServerSession(event); 20 | const { formId } = parseParamsAs(event, paramSchema); 21 | const { skip, take, isSpam } = parseQueryAs(event, querySchema); 22 | if (!session) { 23 | throw createError({ statusMessage: "Unauthenticated", statusCode: 403 }); 24 | } 25 | 26 | const { prisma } = event.context; 27 | 28 | const query = { 29 | where: { 30 | formId, 31 | isSpam, 32 | form: { 33 | workspace: { 34 | users: { 35 | some: { 36 | id: session.user.id, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | }; 43 | const [submissions, total, result] = await prisma.$transaction([ 44 | prisma.submission.findMany({ 45 | where: query.where, 46 | orderBy: { 47 | createdAt: "desc", 48 | }, 49 | skip: skip, 50 | take: take, 51 | }), 52 | prisma.submission.count(query), 53 | prisma.$queryRaw`SELECT jsonb_object_keys(data) AS key FROM public."Submission" WHERE "formId"=${formId} GROUP BY key`, 54 | ]); 55 | 56 | const keys = ((result as any).map((r: any) => r.key) as string[]).filter( 57 | (key) => !key.startsWith("_") 58 | ); 59 | 60 | return { 61 | submissions, 62 | keys, 63 | pagination: { skip, take, total }, 64 | }; 65 | }); 66 | -------------------------------------------------------------------------------- /server/api/forms/[formId]/submissions/csv.get.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "#auth"; 2 | import { z, parseParamsAs, parseQueryAs } from "@sidebase/nuxt-parse"; 3 | import { stringify } from "csv-stringify/sync"; 4 | 5 | const paramSchema = z.object({ 6 | formId: z.string(), 7 | }); 8 | 9 | const querySchema = z.object({ 10 | isSpam: z 11 | .string() 12 | .optional() 13 | .default("false") 14 | .transform((v) => v === "true"), 15 | }); 16 | 17 | export default defineEventHandler(async (event) => { 18 | const session = await getServerSession(event); 19 | const { formId } = parseParamsAs(event, paramSchema); 20 | const { isSpam } = parseQueryAs(event, querySchema); 21 | if (!session) { 22 | throw createError({ statusMessage: "Unauthenticated", statusCode: 403 }); 23 | } 24 | 25 | const { prisma } = event.context; 26 | 27 | const query = { 28 | where: { 29 | formId, 30 | isSpam, 31 | form: { 32 | workspace: { 33 | users: { 34 | some: { 35 | id: session.user.id, 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }; 42 | const submissions = await prisma.submission.findMany({ 43 | where: query.where, 44 | orderBy: { 45 | createdAt: "desc", 46 | }, 47 | select: { 48 | createdAt: true, 49 | data: true, 50 | }, 51 | }); 52 | 53 | const formattedSubmissions = submissions.map((submission) => { 54 | return { 55 | createdAt: new Date(submission.createdAt).toISOString(), 56 | ...(submission.data as object), 57 | }; 58 | }); 59 | 60 | // Convert submissions to CSV 61 | const csv = stringify(formattedSubmissions, { header: true }); 62 | setResponseHeader(event, "Content-Type", "text/csv"); 63 | setResponseHeader( 64 | event, 65 | "Content-Disposition", 66 | "attachment; filename=submissions.csv" 67 | ); 68 | await send(event, csv, "text/csv"); 69 | }); 70 | -------------------------------------------------------------------------------- /server/api/stripe/checkout.post.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "#auth"; 2 | import { z, parseBodyAs } from "@sidebase/nuxt-parse"; 3 | import { createOrRetrieveCustomer, stripe } from "@/utils/stripe"; 4 | 5 | const bodySchema = z.object({ 6 | priceId: z.string(), 7 | }); 8 | 9 | export default defineEventHandler(async (event) => { 10 | const session = await getServerSession(event); 11 | const { priceId } = await parseBodyAs(event, bodySchema); 12 | 13 | if (!session) { 14 | throw createError({ statusMessage: "Unauthenticated", statusCode: 401 }); 15 | } 16 | 17 | const { prisma } = event.context; 18 | 19 | const user = await createOrRetrieveCustomer({ 20 | prisma, 21 | userId: session.user.id, 22 | }); 23 | 24 | if (!user) { 25 | throw createError({ statusMessage: "User not found", statusCode: 404 }); 26 | } 27 | 28 | try { 29 | const stripeSession = await stripe.checkout.sessions.create({ 30 | mode: "subscription", 31 | payment_method_types: ["card"], 32 | client_reference_id: user.id, 33 | customer: user.stripeCustomerId || undefined, 34 | line_items: [ 35 | { 36 | price: priceId, 37 | quantity: 1, 38 | }, 39 | ], 40 | allow_promotion_codes: true, 41 | success_url: `${useRuntimeConfig().public.BASE_URL}/settings/billing`, 42 | cancel_url: `${useRuntimeConfig().public.BASE_URL}/settings/billing`, 43 | }); 44 | if (stripeSession.url) { 45 | return { 46 | stripeSession, 47 | }; 48 | } else { 49 | throw createError({ 50 | statusMessage: "Stripe checkout session error", 51 | message: "Stripe checkout session error", 52 | statusCode: 500, 53 | }); 54 | } 55 | } catch (error: any) { 56 | console.log(error); 57 | 58 | throw createError({ 59 | statusMessage: "Stripe checkout session error", 60 | message: error.message, 61 | statusCode: 500, 62 | }); 63 | } 64 | }); 65 | -------------------------------------------------------------------------------- /prisma/migrations/20230909054307_create_feature_tables/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Workspace" ( 3 | "id" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "updatedAt" TIMESTAMP(3) NOT NULL, 7 | 8 | CONSTRAINT "Workspace_pkey" PRIMARY KEY ("id") 9 | ); 10 | 11 | -- CreateTable 12 | CREATE TABLE "Form" ( 13 | "id" TEXT NOT NULL, 14 | "name" TEXT NOT NULL, 15 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 16 | "updatedAt" TIMESTAMP(3) NOT NULL, 17 | "workspaceId" TEXT NOT NULL, 18 | 19 | CONSTRAINT "Form_pkey" PRIMARY KEY ("id") 20 | ); 21 | 22 | -- CreateTable 23 | CREATE TABLE "Submission" ( 24 | "id" TEXT NOT NULL, 25 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 26 | "data" JSONB NOT NULL, 27 | "formId" TEXT NOT NULL, 28 | 29 | CONSTRAINT "Submission_pkey" PRIMARY KEY ("id") 30 | ); 31 | 32 | -- CreateTable 33 | CREATE TABLE "_UserToWorkspace" ( 34 | "A" TEXT NOT NULL, 35 | "B" TEXT NOT NULL 36 | ); 37 | 38 | -- CreateIndex 39 | CREATE UNIQUE INDEX "_UserToWorkspace_AB_unique" ON "_UserToWorkspace"("A", "B"); 40 | 41 | -- CreateIndex 42 | CREATE INDEX "_UserToWorkspace_B_index" ON "_UserToWorkspace"("B"); 43 | 44 | -- AddForeignKey 45 | ALTER TABLE "Form" ADD CONSTRAINT "Form_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 46 | 47 | -- AddForeignKey 48 | ALTER TABLE "Submission" ADD CONSTRAINT "Submission_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 49 | 50 | -- AddForeignKey 51 | ALTER TABLE "_UserToWorkspace" ADD CONSTRAINT "_UserToWorkspace_A_fkey" FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 52 | 53 | -- AddForeignKey 54 | ALTER TABLE "_UserToWorkspace" ADD CONSTRAINT "_UserToWorkspace_B_fkey" FOREIGN KEY ("B") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; 55 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | devtools: { enabled: true }, 4 | modules: [ 5 | "@nuxt/ui", 6 | "@sidebase/nuxt-auth", 7 | "dayjs-nuxt", 8 | "@pinia/nuxt", 9 | "@productdevbook/chatwoot", 10 | ], 11 | app: { 12 | head: { 13 | title: "OpenformStack", 14 | meta: [ 15 | { 16 | name: "description", 17 | content: 18 | "Open source form backend that allows you to collect form submissions without writing any backend code", 19 | }, 20 | { charset: "utf-8" }, 21 | ], 22 | }, 23 | }, 24 | runtimeConfig: { 25 | GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, 26 | API_ROUTE_SECRET: process.env.API_ROUTE_SECRET, 27 | RESEND_API_KEY: process.env.RESEND_API_KEY, 28 | STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, 29 | STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, 30 | PRO_MONTHLY_PRICE_ID: process.env.PRO_MONTHLY_PRICE_ID, 31 | PRO_YEARLY_PRICE_ID: process.env.PRO_YEARLY_PRICE_ID, 32 | BYESPAM_API_KEY: process.env.BYESPAM_API_KEY, 33 | public: { 34 | FROM_MAIL: process.env.FROM_MAIL, 35 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, 36 | BASE_URL: process.env.BASE_URL, 37 | 38 | // SEO 39 | siteUrl: process.env.BASE_URL || "https://openformstack.com", 40 | siteName: "OpenformStack", 41 | siteDescription: 42 | "Open source form backend that allows you to collect form submissions without writing any backend code", 43 | language: "en", 44 | }, 45 | }, 46 | pinia: { 47 | autoImports: ["defineStore", ["defineStore", "definePiniaStore"]], 48 | }, 49 | colorMode: { 50 | preference: "light", 51 | }, 52 | chatwoot: { 53 | init: { 54 | websiteToken: process.env.CHATWOOT_WEBSITE_TOKEN, 55 | }, 56 | settings: { 57 | locale: "en", 58 | position: "left", 59 | launcherTitle: "Chat", 60 | }, 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /prisma/migrations/20230908150845_create_user_account/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Account" ( 3 | "id" TEXT NOT NULL, 4 | "userId" TEXT NOT NULL, 5 | "type" TEXT NOT NULL, 6 | "provider" TEXT NOT NULL, 7 | "providerAccountId" TEXT NOT NULL, 8 | "refresh_token" TEXT, 9 | "access_token" TEXT, 10 | "expires_at" INTEGER, 11 | "token_type" TEXT, 12 | "scope" TEXT, 13 | "id_token" TEXT, 14 | "session_state" TEXT, 15 | 16 | CONSTRAINT "Account_pkey" PRIMARY KEY ("id") 17 | ); 18 | 19 | -- CreateTable 20 | CREATE TABLE "Session" ( 21 | "id" TEXT NOT NULL, 22 | "sessionToken" TEXT NOT NULL, 23 | "userId" TEXT NOT NULL, 24 | "expires" TIMESTAMP(3) NOT NULL, 25 | 26 | CONSTRAINT "Session_pkey" PRIMARY KEY ("id") 27 | ); 28 | 29 | -- CreateTable 30 | CREATE TABLE "User" ( 31 | "id" TEXT NOT NULL, 32 | "name" TEXT, 33 | "email" TEXT, 34 | "emailVerified" TIMESTAMP(3), 35 | "image" TEXT, 36 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 37 | 38 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 39 | ); 40 | 41 | -- CreateTable 42 | CREATE TABLE "VerificationToken" ( 43 | "identifier" TEXT NOT NULL, 44 | "token" TEXT NOT NULL, 45 | "expires" TIMESTAMP(3) NOT NULL 46 | ); 47 | 48 | -- CreateIndex 49 | CREATE UNIQUE INDEX "Account_userId_key" ON "Account"("userId"); 50 | 51 | -- CreateIndex 52 | CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); 53 | 54 | -- CreateIndex 55 | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); 56 | 57 | -- CreateIndex 58 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 59 | 60 | -- CreateIndex 61 | CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); 62 | 63 | -- CreateIndex 64 | CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); 65 | 66 | -- AddForeignKey 67 | ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 68 | 69 | -- AddForeignKey 70 | ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 71 | -------------------------------------------------------------------------------- /components/Form/Integrations.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /pages/forms/[formId].vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /pages/dashboard.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /pages/workspaces/[workspaceId].vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Openformstack 2 | 3 | This repository contains a Nuxt 3 application for creating and managing headless forms. The application uses Prisma for ORM and provides a user interface for creating and managing workspaces and forms. 4 | 5 | ## Requirements 6 | 1. Node.js( ***version 18 or higher*** ) 7 | 8 | 2. Yarn package installer 9 | 10 | 3. Docker 11 | 12 | 4. Docker compose.exe if the .exe file is not included in the docker setup installation then download it from the docker page 13 | 14 | ## Setup Process 15 | 16 | 1. Clone the repository 17 | 18 | ```bash 19 | git clone https://github.com/your-repo/OpenformStack.git 20 | cd OpenformStack 21 | ``` 22 | 2. Install Dependencies 23 | 24 | ```bash 25 | yarn install 26 | ``` 27 | 3. Create a `.env` file in the root directory of the project by copying the `.env.example` file: 28 | 29 | for linux/macOS users 30 | ```bash 31 | cp .env.example .env 32 | ``` 33 | for windows users 34 | ```bash 35 | copy .env.example .env 36 | ``` 37 | 38 | 4. Create a .env.developer.local file in the root folder of the project 39 | 40 | For macOS/Linux 41 | ```bash 42 | touch .env.development.local 43 | ``` 44 | For windows 45 | ```bash 46 | type nul > .env.development.local 47 | ``` 48 | 5. Copy the code into the .env.development.local file 49 | 50 | ```bash 51 | GOOGLE_CLIENT_ID=508816847621-jd5rqskggrh7veqi4pqjtj3nfleqt405.apps.googleusercontent.com 52 | GOOGLE_CLIENT_SECRET={ask admin for secret} 53 | API_ROUTE_SECRET=(ask admin for secret} 54 | AUTH_ORIGIN="http://localhost:3000" 55 | BASE_URL="http://localhost:3000" 56 | ``` 57 | 58 | 6. Start the PostgreSQL database using Docker Compose: 59 | 60 | ```bash 61 | docker-compose up -d 62 | ``` 63 | 7. Run the prisma database migrations and generate the Prisma client: 64 | 65 | ```bash 66 | yarn prisma:migrate:dev 67 | yarn prisma:migrate:generate 68 | ``` 69 | 8. Start the development server: 70 | 71 | ```bash 72 | yarn dev 73 | ``` 74 | 75 | If you are facing an error in signing up then delete yarn lock file and node_modules file and re-install the yarn packages. 76 | 77 | Yarn commands are often used in the context of database migrations. Here's what each of the commands you mentioned typically does: 78 | 79 | a) yarn prisma:migrate:dev: 80 | 81 | This command is usually used to apply pending database migrations in a development environment. 82 | It runs the migrations that have been generated but not yet applied to the database. 83 | 84 | 85 | b) yarn prisma:migrate:generate: 86 | This command generates new Prisma migration files based on changes in your Prisma schema. 87 | It compares the current state of your database with the Prisma schema and generates migration files that represent the changes needed to bring the database schema in sync with the Prisma schema. 88 | -------------------------------------------------------------------------------- /server/api/stripe/webhook.post.ts: -------------------------------------------------------------------------------- 1 | import { manageSubscriptionStatusChange, stripe } from "@/utils/stripe"; 2 | import Stripe from "stripe"; 3 | 4 | const relevantEvents = new Set([ 5 | "checkout.session.completed", 6 | "customer.subscription.created", 7 | "customer.subscription.updated", 8 | "customer.subscription.deleted", 9 | ]); 10 | 11 | export default defineEventHandler(async (event) => { 12 | const body = await readRawBody(event); 13 | 14 | const sig = event.headers.get("stripe-signature"); 15 | 16 | const webhookSecret = useRuntimeConfig().STRIPE_WEBHOOK_SECRET; 17 | let stripeEvent: Stripe.Event; 18 | 19 | try { 20 | if (!sig || !webhookSecret || !body) return; 21 | stripeEvent = stripe.webhooks.constructEvent( 22 | body, 23 | sig, 24 | useRuntimeConfig().STRIPE_WEBHOOK_SECRET 25 | ); 26 | } catch (err: any) { 27 | console.log("err", err); 28 | 29 | throw createError({ 30 | statusMessage: "Webhook Error", 31 | message: err.message, 32 | statusCode: 400, 33 | }); 34 | } 35 | const { prisma } = event.context; 36 | 37 | if (relevantEvents.has(stripeEvent.type)) { 38 | try { 39 | switch (stripeEvent.type) { 40 | case "customer.subscription.created": 41 | case "customer.subscription.updated": 42 | case "customer.subscription.deleted": 43 | const subscription = stripeEvent.data.object as Stripe.Subscription; 44 | await manageSubscriptionStatusChange( 45 | prisma, 46 | subscription.id, 47 | subscription.customer as string 48 | ); 49 | break; 50 | case "checkout.session.completed": 51 | const checkoutSession = stripeEvent.data 52 | .object as Stripe.Checkout.Session; 53 | if (checkoutSession.mode === "subscription") { 54 | const user = await prisma.user.update({ 55 | where: { 56 | id: checkoutSession.client_reference_id as string, 57 | }, 58 | data: { 59 | stripeCustomerId: checkoutSession.customer as string, 60 | }, 61 | }); 62 | 63 | const subscriptionId = checkoutSession.subscription; 64 | await manageSubscriptionStatusChange( 65 | prisma, 66 | subscriptionId as string, 67 | user?.stripeCustomerId as string 68 | ); 69 | } 70 | break; 71 | default: 72 | break; 73 | } 74 | } catch (error: any) { 75 | throw createError({ 76 | statusMessage: "Webhook Error", 77 | message: error.message, 78 | statusCode: 400, 79 | }); 80 | } 81 | } 82 | 83 | return { 84 | success: true, 85 | }; 86 | }); 87 | -------------------------------------------------------------------------------- /store/workspace.ts: -------------------------------------------------------------------------------- 1 | import type { WorkspaceWithForms } from "~/types"; 2 | 3 | export const useWorkspaceStore = defineStore("workspace", { 4 | state: () => ({ 5 | workspaceWithForms: [] as WorkspaceWithForms[], 6 | showWorkspaceModal: false, 7 | formModalWorkspaceId: "", 8 | }), 9 | actions: { 10 | async getWorkspaceWithForms() { 11 | const { data } = await useFetch("/api/workspaces"); 12 | this.workspaceWithForms = 13 | data.value?.workspaces.map((workspace) => ({ 14 | ...workspace, 15 | createdAt: new Date(workspace.createdAt), 16 | updatedAt: new Date(workspace.updatedAt), 17 | forms: workspace.forms.map((form) => ({ 18 | ...form, 19 | createdAt: new Date(form.createdAt), 20 | updatedAt: new Date(form.updatedAt), 21 | })), 22 | })) ?? []; 23 | }, 24 | 25 | async createWorkspace(name: string) { 26 | const { data } = await useFetch("/api/workspaces", { 27 | method: "POST", 28 | body: { name }, 29 | }); 30 | if (data.value) { 31 | this.workspaceWithForms.push({ 32 | ...data.value.workspace, 33 | createdAt: new Date(data.value.workspace.createdAt), 34 | updatedAt: new Date(data.value.workspace.updatedAt), 35 | forms: [], 36 | }); 37 | this.showWorkspaceModal = false; 38 | navigateTo(`/workspaces/${data.value.workspace.id}`); 39 | } 40 | }, 41 | 42 | async createForm(name: string, workspaceId: string) { 43 | const { data } = await useFetch(`/api/forms`, { 44 | method: "POST", 45 | body: { name, workspaceId }, 46 | }); 47 | if (data.value) { 48 | const workspace = this.workspaceWithForms.find( 49 | (workspace) => workspace.id === data.value?.form.workspaceId 50 | ); 51 | if (workspace) { 52 | workspace.forms.push({ 53 | ...data.value.form, 54 | createdAt: new Date(data.value.form.createdAt), 55 | updatedAt: new Date(data.value.form.updatedAt), 56 | }); 57 | } 58 | this.formModalWorkspaceId = ""; 59 | navigateTo(`/forms/${data.value.form.id}`); 60 | } 61 | }, 62 | 63 | async updateForm(id: string, body: any) { 64 | const { data } = await useFetch(`/api/forms/${id}`, { 65 | method: "PUT", 66 | body, 67 | }); 68 | if (data.value) { 69 | const workspace = this.workspaceWithForms.find( 70 | (workspace) => workspace.id === data.value?.form.workspaceId 71 | ); 72 | if (workspace) { 73 | const formIndex = workspace.forms.findIndex( 74 | (form) => form.id === data.value?.form.id 75 | ); 76 | if (formIndex !== -1) { 77 | workspace.forms[formIndex] = { 78 | ...data.value.form, 79 | createdAt: new Date(data.value.form.createdAt), 80 | updatedAt: new Date(data.value.form.updatedAt), 81 | }; 82 | } 83 | } 84 | } 85 | }, 86 | }, 87 | }); 88 | -------------------------------------------------------------------------------- /pages/contact.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /components/Lp/TopNav.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /utils/stripe.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import Stripe from "stripe"; 3 | 4 | export const stripe = new Stripe(useRuntimeConfig().STRIPE_SECRET_KEY, { 5 | apiVersion: "2023-08-16", 6 | appInfo: { 7 | name: "OpenformStack", 8 | version: "1.0.0", 9 | }, 10 | }); 11 | 12 | export const toDateTime = (secs: number) => { 13 | var t = new Date("1970-01-01T00:30:00Z"); // Unix epoch start. 14 | t.setSeconds(secs); 15 | return t; 16 | }; 17 | 18 | export const createOrRetrieveCustomer = async ({ 19 | prisma, 20 | userId, 21 | }: { 22 | prisma: PrismaClient; 23 | userId: string; 24 | }) => { 25 | const user = await prisma.user.findUnique({ 26 | where: { 27 | id: userId, 28 | }, 29 | }); 30 | 31 | if (!user || !user.stripeCustomerId) { 32 | const customerData: { metadata: { id: string }; email?: string } = { 33 | email: user?.email || undefined, 34 | metadata: { 35 | id: userId, 36 | }, 37 | }; 38 | 39 | const customer = await stripe.customers.create(customerData); 40 | 41 | const updatedUser = await prisma.user.update({ 42 | where: { 43 | id: userId, 44 | }, 45 | data: { 46 | stripeCustomerId: customer.id, 47 | }, 48 | }); 49 | return { ...updatedUser }; 50 | } 51 | return { ...user }; 52 | }; 53 | 54 | export const manageSubscriptionStatusChange = async ( 55 | prisma: PrismaClient, 56 | subscriptionId: string, 57 | stripeCustomerId: string 58 | ) => { 59 | // Get customer's UUID from mapping table. 60 | const user = await prisma.user.findUnique({ 61 | where: { stripeCustomerId }, 62 | }); 63 | 64 | console.log( 65 | `User [${user?.id}] is changing subscription [${subscriptionId}] wth customer [${stripeCustomerId}]` 66 | ); 67 | 68 | if (!user) { 69 | throw new Error("User not found."); 70 | } 71 | 72 | const subscription = await stripe.subscriptions.retrieve(subscriptionId, { 73 | expand: ["default_payment_method"], 74 | }); 75 | 76 | // Upsert the latest status of the subscription object. 77 | const subscriptionData = { 78 | id: subscription.id, 79 | userId: user.id, 80 | status: subscription.status, 81 | priceId: subscription.items.data[0].price.id, 82 | quantity: subscription.items.data[0].quantity, 83 | cancelAtPeriodEnd: subscription.cancel_at_period_end, 84 | cancelAt: subscription.cancel_at 85 | ? toDateTime(subscription.cancel_at).toISOString() 86 | : null, 87 | canceledAt: subscription.canceled_at 88 | ? toDateTime(subscription.canceled_at).toISOString() 89 | : null, 90 | currentPeriodStart: toDateTime( 91 | subscription.current_period_start 92 | ).toISOString(), 93 | currentPeriodEnd: toDateTime(subscription.current_period_end).toISOString(), 94 | created: toDateTime(subscription.created).toISOString(), 95 | endedAt: subscription.ended_at 96 | ? toDateTime(subscription.ended_at).toISOString() 97 | : null, 98 | trialStart: subscription.trial_start 99 | ? toDateTime(subscription.trial_start).toISOString() 100 | : null, 101 | trialEnd: subscription.trial_end 102 | ? toDateTime(subscription.trial_end).toISOString() 103 | : null, 104 | }; 105 | 106 | const result = await prisma.subscription.upsert({ 107 | where: { id: subscriptionData.id }, 108 | create: subscriptionData, 109 | update: subscriptionData, 110 | }); 111 | 112 | console.log( 113 | `Inserted/updated subscription [${result.id}] for user [${result.userId}]` 114 | ); 115 | }; 116 | -------------------------------------------------------------------------------- /server/routes/f/[formId].ts: -------------------------------------------------------------------------------- 1 | import { z, parseParamsAs } from "@sidebase/nuxt-parse"; 2 | import { inngest } from "~/inngest/client"; 3 | import { isEmail } from "~/utils"; 4 | import { kv } from "@vercel/kv"; 5 | import { Ratelimit } from "@upstash/ratelimit"; 6 | 7 | const DEFAULT_REDIRECT_URL = "https://openformstack.com/thank-you"; 8 | 9 | const paramSchema = z.object({ 10 | formId: z.string(), 11 | }); 12 | 13 | export default defineEventHandler(async (event) => { 14 | handleCors(event, { 15 | allowHeaders: ["Content-Type", "Accept", "Origin", "Referer", "User-Agent"], 16 | methods: ["OPTIONS", "POST"], 17 | origin: "*", 18 | preflight: { 19 | statusCode: 204, 20 | }, 21 | }); 22 | if (event.node.req.method === "OPTIONS") { 23 | return null; 24 | } 25 | assertMethod(event, "POST"); 26 | 27 | const body = await readBody(event); 28 | const { formId } = parseParamsAs(event, paramSchema); 29 | const { prisma } = event.context; 30 | 31 | const ip = event.headers.get("x-forwarded-for"); 32 | const ratelimit = new Ratelimit({ 33 | redis: kv, 34 | // rate limit to 5 requests per 60 seconds 35 | limiter: Ratelimit.slidingWindow(5, "60s"), 36 | }); 37 | 38 | const { success, limit, reset, remaining } = await ratelimit.limit( 39 | `ratelimit_${ip}` 40 | ); 41 | 42 | if (!success) { 43 | return new Response("You have reached your request limit for the day.", { 44 | status: 429, 45 | headers: { 46 | "X-RateLimit-Limit": limit.toString(), 47 | "X-RateLimit-Remaining": remaining.toString(), 48 | "X-RateLimit-Reset": reset.toString(), 49 | }, 50 | }); 51 | } 52 | 53 | const form = await prisma.form.findUnique({ 54 | where: { 55 | id: formId, 56 | }, 57 | include: { 58 | workspace: { 59 | include: { 60 | users: true, 61 | }, 62 | }, 63 | }, 64 | }); 65 | if (!form) { 66 | setResponseStatus(event, 404); 67 | setResponseHeader(event, "Content-Type", "application/json"); 68 | return { 69 | error: { 70 | code: "NOT_FOUND", 71 | message: "Form not found. Please check the url again", 72 | }, 73 | }; 74 | } 75 | if (form.closed) { 76 | setResponseStatus(event, 422); 77 | setResponseHeader(event, "Content-Type", "application/json"); 78 | return { 79 | error: { 80 | code: "FORM_CLOSED", 81 | message: "Form is closed", 82 | }, 83 | }; 84 | } 85 | const headers = event.headers; 86 | const contentType = headers.get("content-type") || ""; 87 | 88 | // Body Parsing 89 | let parsedBody: any; 90 | if (contentType.includes("application/json")) { 91 | parsedBody = typeof body === "object" ? body : JSON.parse(body); 92 | } else if (contentType.includes("application/x-www-form-urlencoded")) { 93 | parsedBody = Object.fromEntries(new URLSearchParams(body).entries()); 94 | } else { 95 | setResponseStatus(event, 406); 96 | setResponseHeader(event, "Content-Type", "application/json"); 97 | return { 98 | error: { 99 | code: "INVALID_CONTENT_TYPE", 100 | message: 101 | "Accepts only application/json or application/x-www-form-urlencoded", 102 | }, 103 | }; 104 | } 105 | 106 | const submission = await prisma.submission.create({ 107 | data: { 108 | formId, 109 | data: body, 110 | }, 111 | }); 112 | 113 | await inngest.send({ 114 | name: "app/form.backgroundJob", 115 | data: { 116 | formId, 117 | submissionId: submission.id, 118 | }, 119 | }); 120 | 121 | return sendRedirect( 122 | event, 123 | form.customRedirect 124 | ? form.customRedirectUrl ?? DEFAULT_REDIRECT_URL 125 | : DEFAULT_REDIRECT_URL 126 | ); 127 | }); 128 | -------------------------------------------------------------------------------- /components/Form/Setup.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /components/Lp/FeatureSection.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model Account { 14 | id String @id @default(cuid()) 15 | userId String @unique 16 | type String 17 | provider String 18 | providerAccountId String 19 | refresh_token String? 20 | access_token String? 21 | expires_at Int? 22 | token_type String? 23 | scope String? 24 | id_token String? 25 | session_state String? 26 | 27 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 28 | 29 | @@unique([provider, providerAccountId]) 30 | } 31 | 32 | model Session { 33 | id String @id @default(cuid()) 34 | sessionToken String @unique 35 | userId String 36 | expires DateTime 37 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 38 | } 39 | 40 | model User { 41 | id String @id @default(cuid()) 42 | name String? 43 | email String? @unique 44 | emailVerified DateTime? 45 | image String? 46 | createdAt DateTime @default(now()) 47 | account Account[] 48 | sessions Session[] 49 | workspaces Workspace[] @relation("UserToWorkspace") 50 | stripeCustomerId String? @unique 51 | Subscription Subscription[] 52 | } 53 | 54 | model VerificationToken { 55 | identifier String 56 | token String @unique 57 | expires DateTime 58 | 59 | @@unique([identifier, token]) 60 | } 61 | 62 | model Workspace { 63 | id String @id @default(cuid()) 64 | name String 65 | createdAt DateTime @default(now()) 66 | updatedAt DateTime @updatedAt 67 | users User[] @relation("UserToWorkspace") 68 | forms Form[] 69 | } 70 | 71 | model Form { 72 | id String @id @default(cuid()) 73 | name String 74 | closed Boolean @default(false) 75 | // send email to self when form is submitted 76 | selfEmailNotification Boolean @default(true) 77 | selfEmails String[] 78 | 79 | // send email to respondent when form is submitted 80 | respondentEmailNotification Boolean @default(false) 81 | fromName String? 82 | subject String? 83 | message String? 84 | 85 | // redirect to custom url when form is submitted 86 | customRedirect Boolean @default(false) 87 | customRedirectUrl String? 88 | // webhook url to send data to when form is submitted 89 | webhookEnabled Boolean @default(false) 90 | webhookUrl String? 91 | createdAt DateTime @default(now()) 92 | updatedAt DateTime @updatedAt 93 | workspace Workspace @relation(fields: [workspaceId], references: [id]) 94 | workspaceId String 95 | submissions Submission[] 96 | } 97 | 98 | model Submission { 99 | id String @id @default(cuid()) 100 | createdAt DateTime @default(now()) 101 | data Json 102 | isSpam Boolean @default(false) 103 | form Form @relation(fields: [formId], references: [id]) 104 | formId String 105 | } 106 | 107 | model Subscription { 108 | id String @id 109 | status String? 110 | priceId String? 111 | quantity Int? 112 | cancelAtPeriodEnd Boolean? 113 | created String 114 | currentPeriodStart String 115 | currentPeriodEnd String 116 | endedAt String? 117 | cancelAt String? 118 | canceledAt String? 119 | trialStart String? 120 | trialEnd String? 121 | 122 | user User @relation(fields: [userId], references: [id]) 123 | userId String 124 | } 125 | -------------------------------------------------------------------------------- /components/Lp/Footer.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /inngest/functions/formBackgroundJob.ts: -------------------------------------------------------------------------------- 1 | import { inngest } from "@/inngest/client"; 2 | import { Prisma, PrismaClient } from "@prisma/client"; 3 | import { NonRetriableError } from "inngest"; 4 | import { Resend } from "resend"; 5 | import { isEmail } from "~/utils"; 6 | 7 | export default inngest.createFunction( 8 | { id: "Form Background Job" }, 9 | { event: "app/form.backgroundJob" }, 10 | async ({ event, step }) => { 11 | const formId = event.data.formId; 12 | if (!formId) { 13 | throw new NonRetriableError("Form ID is required"); 14 | } 15 | 16 | const submissionId = event.data.submissionId; 17 | if (!submissionId) { 18 | throw new NonRetriableError("Submission ID is required"); 19 | } 20 | 21 | const prisma = new PrismaClient(); 22 | 23 | const [form, submission] = await prisma.$transaction([ 24 | prisma.form.findUnique({ 25 | where: { id: formId }, 26 | include: { workspace: { include: { users: true } } }, 27 | }), 28 | prisma.submission.findUnique({ 29 | where: { id: submissionId }, 30 | }), 31 | ]); 32 | if (!form) { 33 | throw new NonRetriableError("Form not found"); 34 | } 35 | 36 | if (!submission) { 37 | throw new NonRetriableError("Submission not found"); 38 | } 39 | 40 | //Step 1: Check for spam 41 | const body = submission.data; 42 | if (!body) { 43 | throw new NonRetriableError("Submission data not found"); 44 | } 45 | 46 | const isSpam = await step.run("checkSpam", async () => { 47 | const isSpam = Object.entries(body).some( 48 | ([key, value]) => key.startsWith("_") && value 49 | ); 50 | const { spam } = await $fetch<{ spam: boolean }>( 51 | "https://byespam.co/api/v1/spamdetection", 52 | { 53 | method: "POST", 54 | headers: { 55 | "Content-Type": "application/json", 56 | Authorization: `Bearer ${useRuntimeConfig().BYESPAM_API_KEY}`, 57 | }, 58 | body: { 59 | content: JSON.stringify(body), 60 | }, 61 | } 62 | ); 63 | 64 | if (isSpam || spam) { 65 | await prisma.submission.update({ 66 | where: { id: submissionId }, 67 | data: { isSpam: true }, 68 | }); 69 | } 70 | return isSpam || spam; 71 | }); 72 | 73 | if (isSpam) { 74 | return { event }; 75 | } 76 | 77 | // self email notification 78 | const resend = new Resend(useRuntimeConfig().RESEND_API_KEY); 79 | 80 | const formName = form.name; 81 | const userEmails = form.workspace.users 82 | .map((user) => user.email) 83 | .filter((email): email is string => Boolean(email)); 84 | const selfEmails = form.selfEmails; 85 | if (form.selfEmailNotification) { 86 | await step.run("app/email.selfNotification", async () => { 87 | await resend.emails.send({ 88 | from: `OpenformStack <${useRuntimeConfig().public.FROM_MAIL}>`, 89 | to: userEmails.concat(selfEmails), 90 | subject: `New submission for ${formName}`, 91 | html: ` 92 | ${Object.entries(body) 93 | .map(([key, value]) => `
${key}: ${value}
`) 94 | .join("")} 95 | `, 96 | }); 97 | }); 98 | } 99 | // respondent email notification 100 | if (form.respondentEmailNotification) { 101 | await step.run("app/email.respondentNotification", async () => { 102 | if (typeof body === "object" && "email" in body) { 103 | const email = (body as Prisma.JsonObject)["email"] as string; 104 | if (email && isEmail(email)) { 105 | await resend.emails.send({ 106 | from: `${form.fromName ?? "OpenformStack"} <${ 107 | useRuntimeConfig().public.FROM_MAIL 108 | }>`, 109 | to: email, 110 | subject: form.subject ?? "Thank you for your submission", 111 | text: 112 | form.message ?? 113 | "Thanks for reaching out! we'll get back to you as soon as possible.", 114 | reply_to: userEmails ?? [], 115 | }); 116 | } 117 | } 118 | }); 119 | } 120 | 121 | // webhook 122 | if (form.webhookEnabled) { 123 | await step.run("app/webhook", async () => { 124 | if (form.webhookUrl) { 125 | await $fetch(form.webhookUrl, { 126 | method: "POST", 127 | body: JSON.stringify({ 128 | formName: form.name, 129 | submission, 130 | }), 131 | }); 132 | } 133 | }); 134 | } 135 | return { event }; 136 | } 137 | ); 138 | -------------------------------------------------------------------------------- /components/Form/Submissions.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 196 | 197 | 198 | -------------------------------------------------------------------------------- /pages/pricing.vue: -------------------------------------------------------------------------------- 1 | 122 | 123 | 166 | 167 | 168 | -------------------------------------------------------------------------------- /components/Lp/HeroSection.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 178 | 179 | 180 | -------------------------------------------------------------------------------- /pages/refund.vue: -------------------------------------------------------------------------------- 1 | 167 | 168 | 173 | 174 | 175 | -------------------------------------------------------------------------------- /components/Settings/Billing.vue: -------------------------------------------------------------------------------- 1 | 134 | 135 | 233 | 234 | 235 | -------------------------------------------------------------------------------- /components/Form/Settings.vue: -------------------------------------------------------------------------------- 1 | 199 | 200 | 275 | 276 | 277 | -------------------------------------------------------------------------------- /pages/terms.vue: -------------------------------------------------------------------------------- 1 | 293 | 294 | 299 | 300 | 301 | -------------------------------------------------------------------------------- /pages/privacy.vue: -------------------------------------------------------------------------------- 1 | 522 | 523 | 528 | --------------------------------------------------------------------------------