diff --git a/.env.example b/.env.example index db986396c9..b90eb507c6 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ WEBAPP_URL=http://localhost:3000 +SHORT_SURVEY_BASE_URL=http://localhost:3000/i + ############## # DATABASE # ############## @@ -108,4 +110,6 @@ CRON_SECRET= # Encryption key # You can use: `openssl rand -base64 16` to generate one FORMBRICKS_ENCRYPTION_KEY= -*/ + +SHORT_SURVEY_BASE_URL= +*/ diff --git a/apps/web/app/(app)/environments/[environmentId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions.ts index ed9c7fc822..bd1091ffab 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions.ts @@ -2,17 +2,33 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { prisma } from "@formbricks/database"; +import { SHORT_SURVEY_BASE_URL, SURVEY_BASE_URL } from "@formbricks/lib/constants"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { createMembership } from "@formbricks/lib/membership/service"; import { createProduct } from "@formbricks/lib/product/service"; -import { createTeam, getTeamByEnvironmentId } from "@formbricks/lib/team/service"; +import { createShortUrl } from "@formbricks/lib/shortUrl/service"; import { canUserAccessSurvey } from "@formbricks/lib/survey/auth"; import { deleteSurvey, getSurvey } from "@formbricks/lib/survey/service"; -import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/v1/errors"; +import { createTeam, getTeamByEnvironmentId } from "@formbricks/lib/team/service"; +import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/v1/errors"; import { Team } from "@prisma/client"; import { Prisma as prismaClient } from "@prisma/client/"; import { getServerSession } from "next-auth"; +export const createShortUrlAction = async (url: string) => { + const session = await getServerSession(authOptions); + if (!session) throw new AuthenticationError("Not authenticated"); + + const regexPattern = new RegExp("^" + SURVEY_BASE_URL); + const isValidUrl = regexPattern.test(url); + + if (!isValidUrl) throw new Error("Only Formbricks survey URLs are allowed"); + + const shortUrl = await createShortUrl(url); + const fullShortUrl = SHORT_SURVEY_BASE_URL + shortUrl.id; + return fullShortUrl; +}; + export async function createTeamAction(teamName: string): Promise { const session = await getServerSession(authOptions); if (!session) throw new AuthorizationError("Not authorized"); diff --git a/apps/web/app/(app)/environments/[environmentId]/AddProductModal.tsx b/apps/web/app/(app)/environments/[environmentId]/components/AddProductModal.tsx similarity index 100% rename from apps/web/app/(app)/environments/[environmentId]/AddProductModal.tsx rename to apps/web/app/(app)/environments/[environmentId]/components/AddProductModal.tsx diff --git a/apps/web/app/(app)/environments/[environmentId]/EnvironmentsNavbar.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentsNavbar.tsx similarity index 89% rename from apps/web/app/(app)/environments/[environmentId]/EnvironmentsNavbar.tsx rename to apps/web/app/(app)/environments/[environmentId]/components/EnvironmentsNavbar.tsx index 1468ed030d..7bf21db525 100644 --- a/apps/web/app/(app)/environments/[environmentId]/EnvironmentsNavbar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentsNavbar.tsx @@ -1,7 +1,7 @@ export const revalidate = REVALIDATION_INTERVAL; -import Navigation from "@/app/(app)/environments/[environmentId]/Navigation"; -import { IS_FORMBRICKS_CLOUD, REVALIDATION_INTERVAL } from "@formbricks/lib/constants"; +import Navigation from "@/app/(app)/environments/[environmentId]/components/Navigation"; +import { IS_FORMBRICKS_CLOUD, REVALIDATION_INTERVAL, SURVEY_BASE_URL } from "@formbricks/lib/constants"; import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service"; import { getProducts } from "@formbricks/lib/product/service"; import { getTeamByEnvironmentId, getTeamsByUserId } from "@formbricks/lib/team/service"; @@ -43,6 +43,7 @@ export default async function EnvironmentsNavbar({ environmentId, session }: Env environments={environments} session={session} isFormbricksCloud={IS_FORMBRICKS_CLOUD} + surveyBaseUrl={SURVEY_BASE_URL} /> ); } diff --git a/apps/web/app/(app)/environments/[environmentId]/NavbarLoading.tsx b/apps/web/app/(app)/environments/[environmentId]/components/NavbarLoading.tsx similarity index 100% rename from apps/web/app/(app)/environments/[environmentId]/NavbarLoading.tsx rename to apps/web/app/(app)/environments/[environmentId]/components/NavbarLoading.tsx diff --git a/apps/web/app/(app)/environments/[environmentId]/Navigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/Navigation.tsx similarity index 97% rename from apps/web/app/(app)/environments/[environmentId]/Navigation.tsx rename to apps/web/app/(app)/environments/[environmentId]/components/Navigation.tsx index 00a054f784..447dbd0045 100644 --- a/apps/web/app/(app)/environments/[environmentId]/Navigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/Navigation.tsx @@ -17,6 +17,7 @@ import { DropdownMenuTrigger, } from "@/components/shared/DropdownMenu"; import CreateTeamModal from "@/components/team/CreateTeamModal"; +import UrlShortenerModal from "./UrlShortenerModal"; import { formbricksLogout } from "@/lib/formbricks"; import { capitalizeFirstLetter, truncate } from "@/lib/utils"; import formbricks from "@formbricks/js"; @@ -52,6 +53,7 @@ import { PlusIcon, UserCircleIcon, UsersIcon, + LinkIcon, } from "@heroicons/react/24/solid"; import clsx from "clsx"; import { MenuIcon } from "lucide-react"; @@ -71,6 +73,7 @@ interface NavigationProps { products: TProduct[]; environments: TEnvironment[]; isFormbricksCloud: boolean; + surveyBaseUrl: string; } export default function Navigation({ @@ -81,6 +84,7 @@ export default function Navigation({ products, environments, isFormbricksCloud, + surveyBaseUrl, }: NavigationProps) { const router = useRouter(); const pathname = usePathname(); @@ -89,6 +93,7 @@ export default function Navigation({ const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false); const [showAddProductModal, setShowAddProductModal] = useState(false); const [showCreateTeamModal, setShowCreateTeamModal] = useState(false); + const [showLinkShortenerModal, setShowLinkShortenerModal] = useState(false); const product = products.find((product) => product.id === environment.productId); const [mobileNavMenuOpen, setMobileNavMenuOpen] = useState(false); @@ -185,6 +190,14 @@ export default function Navigation({ href: `/environments/${environment.id}/settings/setup`, hidden: widgetSetupCompleted, }, + { + icon: LinkIcon, + label: "Link Shortener", + href: pathname, + onClick: () => { + setShowLinkShortenerModal(true); + }, + }, { icon: CodeBracketIcon, label: "Developer Docs", @@ -441,7 +454,7 @@ export default function Navigation({ (link) => !link.hidden && ( - +
{link.label} @@ -489,6 +502,11 @@ export default function Navigation({ environmentId={environment.id} /> setShowCreateTeamModal(val)} /> + setShowLinkShortenerModal(val)} + surveyBaseUrl={surveyBaseUrl} + /> )} diff --git a/apps/web/app/(app)/environments/[environmentId]/ResponseFilterContext.tsx b/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.tsx similarity index 100% rename from apps/web/app/(app)/environments/[environmentId]/ResponseFilterContext.tsx rename to apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.tsx diff --git a/apps/web/app/(app)/environments/[environmentId]/components/UrlShortenerModal.tsx b/apps/web/app/(app)/environments/[environmentId]/components/UrlShortenerModal.tsx new file mode 100644 index 0000000000..9f828720a9 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/components/UrlShortenerModal.tsx @@ -0,0 +1,152 @@ +import Modal from "@/components/shared/Modal"; +import { Button, Input, Label } from "@formbricks/ui"; +import { LinkIcon } from "@heroicons/react/24/outline"; +import clsx from "clsx"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import toast from "react-hot-toast"; +import { createShortUrlAction } from "../actions"; + +type UrlShortenerModalProps = { + open: boolean; + setOpen: (v: boolean) => void; + surveyBaseUrl: string; +}; +type UrlShortenerFormDataProps = { + url: string; +}; +type UrlValidationState = "default" | "valid" | "invalid"; + +export default function UrlShortenerModal({ open, setOpen, surveyBaseUrl }: UrlShortenerModalProps) { + const [urlValidationState, setUrlValidationState] = useState("default"); + const [shortUrl, setShortUrl] = useState(""); + const { + register, + handleSubmit, + watch, + formState: { isSubmitting }, + } = useForm({ + mode: "onSubmit", + defaultValues: { + url: "", + }, + }); + + const handleUrlValidation = () => { + const value = watch("url").trim(); + if (!value) { + setUrlValidationState("default"); + return; + } + + const regexPattern = new RegExp("^" + surveyBaseUrl); + const isValid = regexPattern.test(value); + if (!isValid) { + setUrlValidationState("invalid"); + toast.error("Only formbricks survey links allowed."); + } else { + setUrlValidationState("valid"); + } + }; + + const shortenUrl = async (data: UrlShortenerFormDataProps) => { + if (urlValidationState !== "valid") return; + + const shortUrl = await createShortUrlAction(data.url.trim()); + setShortUrl(shortUrl); + }; + + const resetForm = () => { + setUrlValidationState("default"); + setShortUrl(""); + }; + + const copyShortUrlToClipboard = () => { + navigator.clipboard.writeText(shortUrl); + toast.success("URL copied to clipboard!"); + }; + + return ( + { + setOpen(v); + resetForm(); + }} + noPadding + closeOnOutsideClick={false}> +
+
+
+
+
+ +
+
+
URL shortener
+
+ Create a short URL to make URL params less obvious. +
+
+
+
+
+
+
+ +
+ + +
+
+
+
+ +
+ { + if (shortUrl) { + copyShortUrlToClipboard(); + } + }}> + {shortUrl} + + +
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/layout.tsx index 4823a18f26..0cdf5abcd7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/layout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/layout.tsx @@ -1,10 +1,10 @@ -import EnvironmentsNavbar from "@/app/(app)/environments/[environmentId]/EnvironmentsNavbar"; +import EnvironmentsNavbar from "@/app/(app)/environments/[environmentId]/components/EnvironmentsNavbar"; import ToasterClient from "@/components/ToasterClient"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import FormbricksClient from "../../FormbricksClient"; -import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext"; +import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { AuthorizationError } from "@formbricks/types/v1/errors"; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx index eb638264af..55cfa09712 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx @@ -4,7 +4,7 @@ import SummaryHeader from "@/app/(app)/environments/[environmentId]/surveys/[sur import SurveyResultsTabs from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyResultsTabs"; import ResponseTimeline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTimeline"; import ContentWrapper from "@/components/shared/ContentWrapper"; -import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext"; +import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; import { getFilterResponses } from "@/lib/surveys/surveys"; import { TResponse } from "@formbricks/types/v1/responses"; import { TSurvey } from "@formbricks/types/v1/surveys"; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx index 22c268f479..1e5efc787d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx @@ -6,7 +6,7 @@ import SurveyResultsTabs from "@/app/(app)/environments/[environmentId]/surveys/ import SummaryList from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList"; import SummaryMetadata from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata"; import ContentWrapper from "@/components/shared/ContentWrapper"; -import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext"; +import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; import { getFilterResponses } from "@/lib/surveys/surveys"; import { TResponse } from "@formbricks/types/v1/responses"; import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys"; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/CustomFilter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/CustomFilter.tsx index d87bcf1dc2..ebc25ecb15 100755 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/CustomFilter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/CustomFilter.tsx @@ -23,7 +23,10 @@ import { TResponse } from "@formbricks/types/v1/responses"; import { TSurvey } from "@formbricks/types/v1/surveys"; import { createId } from "@paralleldrive/cuid2"; import ResponseFilter from "./ResponseFilter"; -import { DateRange, useResponseFilter } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext"; +import { + DateRange, + useResponseFilter, +} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; import { TTag } from "@formbricks/types/v1/tags"; enum DateSelected { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/ResponseFilter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/ResponseFilter.tsx index 45c8db087b..cf532da34e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/ResponseFilter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/ResponseFilter.tsx @@ -1,6 +1,6 @@ "use client"; -import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext"; +import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; import QuestionFilterComboBox from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/QuestionFilterComboBox"; import { TSurveyQuestionType } from "@formbricks/types/v1/surveys"; import { Button, Checkbox, Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui"; diff --git a/apps/web/app/i/[shortUrlId]/loading.tsx b/apps/web/app/i/[shortUrlId]/loading.tsx new file mode 100644 index 0000000000..0ef174a70e --- /dev/null +++ b/apps/web/app/i/[shortUrlId]/loading.tsx @@ -0,0 +1,10 @@ +export default function Loading() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/i/[shortUrlId]/page.tsx b/apps/web/app/i/[shortUrlId]/page.tsx new file mode 100644 index 0000000000..98d4d81961 --- /dev/null +++ b/apps/web/app/i/[shortUrlId]/page.tsx @@ -0,0 +1,29 @@ +import { notFound, redirect } from "next/navigation"; +import { getShortUrl } from "@formbricks/lib/shortUrl/service"; +import { ZShortUrlId } from "@formbricks/types/v1/shortUrl"; + +export default async function ShortUrlPage({ params }) { + if (!params.shortUrlId) { + notFound(); + } + + if (ZShortUrlId.safeParse(params.shortUrlId).success !== true) { + // return not found if unable to parse short url id + notFound(); + } + + let shortUrl; + + try { + shortUrl = await getShortUrl(params.shortUrlId); + } catch (error) { + console.error(error.message); + } + + if (shortUrl) { + redirect(shortUrl.url); + } + + // return not found if short url not found + notFound(); +} diff --git a/apps/web/env.mjs b/apps/web/env.mjs index 4264843f21..6ff26d2c87 100644 --- a/apps/web/env.mjs +++ b/apps/web/env.mjs @@ -48,7 +48,8 @@ export const env = createEnv({ INVITE_DISABLED: z.enum(["1", "0"]).optional(), IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(), VERCEL_URL: z.string().optional(), - SURVEY_BASE_URL: z.string().optional(), + SURVEY_BASE_URL: z.string().url().optional(), + SHORT_SURVEY_BASE_URL: z.string().url().optional().or(z.string().length(0)), GOOGLE_SHEETS_CLIENT_ID: z.string().optional(), GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(), GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(), @@ -118,6 +119,7 @@ export const env = createEnv({ FORMBRICKS_ENCRYPTION_KEY: process.env.FORMBRICKS_ENCRYPTION_KEY, VERCEL_URL: process.env.VERCEL_URL, SURVEY_BASE_URL: process.env.SURVEY_BASE_URL, + SHORT_SURVEY_BASE_URL: process.env.SHORT_SURVEY_BASE_URL, NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, }, }); diff --git a/apps/web/lib/surveys/surveys.ts b/apps/web/lib/surveys/surveys.ts index df09243857..3a4db673fc 100644 --- a/apps/web/lib/surveys/surveys.ts +++ b/apps/web/lib/surveys/surveys.ts @@ -1,7 +1,7 @@ import { DateRange, SelectedFilterValue, -} from "@/app/(app)/environments/[environmentId]/ResponseFilterContext"; +} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; import { OptionsType, QuestionOptions, diff --git a/packages/database/migrations/20231004115913_add_short_url/migration.sql b/packages/database/migrations/20231004115913_add_short_url/migration.sql new file mode 100644 index 0000000000..cfe9e95eca --- /dev/null +++ b/packages/database/migrations/20231004115913_add_short_url/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "ShortUrl" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "url" TEXT NOT NULL, + + CONSTRAINT "ShortUrl_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ShortUrl_url_key" ON "ShortUrl"("url"); diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index 48825e745d..2867b30224 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -513,3 +513,10 @@ model User { /// [UserNotificationSettings] notificationSettings Json @default("{}") } + +model ShortUrl { + id String @id // generate nanoId in service + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + url String @unique +} diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 10047eb71d..dda34a8948 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -13,6 +13,10 @@ export const WEBAPP_URL = export const SURVEY_BASE_URL = env.SURVEY_BASE_URL ? env.SURVEY_BASE_URL + "/" : `${WEBAPP_URL}/s/`; +export const SHORT_SURVEY_BASE_URL = env.SHORT_SURVEY_BASE_URL + ? env.SHORT_SURVEY_BASE_URL + "/" + : `${WEBAPP_URL}/i/`; + // Other export const INTERNAL_SECRET = process.env.INTERNAL_SECRET || ""; export const FORMBRICKS_ENCRYPTION_KEY = env.FORMBRICKS_ENCRYPTION_KEY || undefined; diff --git a/packages/lib/package.json b/packages/lib/package.json index 94fadbfe0d..efc2436e1d 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -19,6 +19,7 @@ "next-auth": "^4.22.3", "jsonwebtoken": "^9.0.2", "markdown-it": "^13.0.2", + "nanoid": "^5.0.1", "nodemailer": "^6.9.5", "posthog-node": "^3.1.2", "server-only": "^0.0.1", diff --git a/packages/lib/shortUrl/service.ts b/packages/lib/shortUrl/service.ts new file mode 100644 index 0000000000..9dfb34f93e --- /dev/null +++ b/packages/lib/shortUrl/service.ts @@ -0,0 +1,72 @@ +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/v1/errors"; +import { TShortUrl, ZShortUrlId } from "@formbricks/types/v1/shortUrl"; +import { Prisma } from "@prisma/client"; +import { customAlphabet } from "nanoid"; +import { validateInputs } from "../utils/validate"; +import z from "zod"; + +// Create the short url and return it +export const createShortUrl = async (url: string): Promise => { + validateInputs([url, z.string().url()]); + + try { + // Check if an entry with the provided fullUrl already exists. + const existingShortUrl = await getShortUrlByUrl(url); + + if (existingShortUrl) { + return existingShortUrl; + } + + // If an entry with the provided fullUrl does not exist, create a new one. + const id = customAlphabet("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 10)(); + + return await prisma.shortUrl.create({ + data: { + id, + url, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } +}; + +// Get the full url from short url and return it +export const getShortUrl = async (id: string): Promise => { + validateInputs([id, ZShortUrlId]); + try { + return await prisma.shortUrl.findUnique({ + where: { + id, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } +}; + +export const getShortUrlByUrl = async (url: string): Promise => { + validateInputs([url, z.string().url()]); + try { + return await prisma.shortUrl.findUnique({ + where: { + url, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } +}; diff --git a/packages/types/v1/shortUrl.ts b/packages/types/v1/shortUrl.ts new file mode 100644 index 0000000000..5ffd7317c7 --- /dev/null +++ b/packages/types/v1/shortUrl.ts @@ -0,0 +1,14 @@ +import z from "zod"; + +export const ZShortUrlId = z.string().length(10); + +export type TShortUrlId = z.infer; + +export const ZShortUrl = z.object({ + id: ZShortUrlId, + createdAt: z.date(), + updatedAt: z.date(), + url: z.string().url(), +}); + +export type TShortUrl = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 259452ba58..16192e1068 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -447,7 +447,7 @@ importers: version: 9.0.0(eslint@8.50.0) eslint-config-turbo: specifier: latest - version: 1.10.3(eslint@8.50.0) + version: 1.8.8(eslint@8.50.0) eslint-plugin-react: specifier: 7.33.2 version: 7.33.2(eslint@8.50.0) @@ -553,6 +553,9 @@ importers: markdown-it: specifier: ^13.0.2 version: 13.0.2 + nanoid: + specifier: ^5.0.1 + version: 5.0.1 next-auth: specifier: ^4.22.3 version: 4.23.1(next@13.5.3)(nodemailer@6.9.5)(react-dom@18.2.0)(react@18.2.0) @@ -11509,13 +11512,13 @@ packages: resolution: {integrity: sha512-NB/L/1Y30qyJcG5xZxCJKW/+bqyj+llbcCwo9DEz8bESIP0SLTOQ8T1DWCCFc+wJ61AMEstj4511PSScqMMfCw==} dev: true - /eslint-config-turbo@1.10.3(eslint@8.50.0): - resolution: {integrity: sha512-ggzPfTJfMsMS383oZ4zfTP1zQvyMyiigOQJRUnLt1nqII6SKkTzdKZdwmXRDHU24KFwUfEFtT6c8vnm2VhL0uQ==} + /eslint-config-turbo@1.8.8(eslint@8.50.0): + resolution: {integrity: sha512-+yT22sHOT5iC1sbBXfLIdXfbZuiv9bAyOXsxTxFCWelTeFFnANqmuKB3x274CFvf7WRuZ/vYP/VMjzU9xnFnxA==} peerDependencies: eslint: '>6.6.0' dependencies: eslint: 8.50.0 - eslint-plugin-turbo: 1.10.3(eslint@8.50.0) + eslint-plugin-turbo: 1.8.8(eslint@8.50.0) dev: true /eslint-import-resolver-node@0.3.6: @@ -11729,8 +11732,8 @@ packages: semver: 6.3.1 string.prototype.matchall: 4.0.8 - /eslint-plugin-turbo@1.10.3(eslint@8.50.0): - resolution: {integrity: sha512-g3Mnnk7el1FqxHfqbE/MayLvCsYjA/vKmAnUj66kV4AlM7p/EZqdt42NMcMSKtDVEm0w+utQkkzWG2Xsa0Pd/g==} + /eslint-plugin-turbo@1.8.8(eslint@8.50.0): + resolution: {integrity: sha512-zqyTIvveOY4YU5jviDWw9GXHd4RiKmfEgwsjBrV/a965w0PpDwJgEUoSMB/C/dU310Sv9mF3DSdEjxjJLaw6rA==} peerDependencies: eslint: '>6.6.0' dependencies: @@ -17188,6 +17191,12 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + /nanoid@5.0.1: + resolution: {integrity: sha512-vWeVtV5Cw68aML/QaZvqN/3QQXc6fBfIieAlu05m7FZW2Dgb+3f0xc0TTxuJW+7u30t7iSDTV/j3kVI0oJqIfQ==} + engines: {node: ^18 || >=20} + hasBin: true + dev: false + /nanomatch@1.2.13: resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==} engines: {node: '>=0.10.0'} @@ -18697,23 +18706,6 @@ packages: postcss: 8.4.27 yaml: 2.3.1 - /postcss-load-config@4.0.1(postcss@8.4.31): - resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==} - engines: {node: '>= 14'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - dependencies: - lilconfig: 2.1.0 - postcss: 8.4.31 - yaml: 2.3.1 - dev: true - /postcss-loader@4.3.0(postcss@8.4.27)(webpack@4.46.0): resolution: {integrity: sha512-M/dSoIiNDOo8Rk0mUqoj4kpGq91gcxCfb9PoyZVdZ76/AuhxylHDYZblNE8o+EQ9AMSASeMFEKxZf5aU6wlx1Q==} engines: {node: '>= 10.13.0'} @@ -23158,7 +23150,7 @@ packages: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 4.0.1(postcss@8.4.31) + postcss-load-config: 4.0.1(postcss@8.4.27) resolve-from: 5.0.0 rollup: 3.5.1 source-map: 0.8.0-beta.0 diff --git a/turbo.json b/turbo.json index d192f95f70..179f079edf 100644 --- a/turbo.json +++ b/turbo.json @@ -74,6 +74,7 @@ "IMPRINT_URL", "NEXT_PUBLIC_SENTRY_DSN", "SURVEY_BASE_URL", + "SHORT_SURVEY_BASE_URL", "NODE_ENV", "NEXT_PUBLIC_POSTHOG_API_HOST", "NEXT_PUBLIC_POSTHOG_API_KEY",