mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-07 00:40:10 -06:00
feat: add Url Shortener (#895)
* WIP * added prisma actions * remove console.logs * some more fixes * tweaks * addressed all PR review comments * remove hits from the prisma schema and all its corresponding service logic * add nanoid * corrected placeholders * change database model, bring shortUrl service up to Formbricks code conventions * update UI and shortUrl endpoint to work with new service --------- Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com> Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -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=
|
||||
*/
|
||||
|
||||
@@ -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<Team> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 href={link.href} target={link.target} key={link.label}>
|
||||
<DropdownMenuItem key={link.label}>
|
||||
<DropdownMenuItem key={link.label} onClick={link?.onClick}>
|
||||
<div className="flex items-center">
|
||||
<link.icon className="mr-2 h-4 w-4" />
|
||||
<span>{link.label}</span>
|
||||
@@ -489,6 +502,11 @@ export default function Navigation({
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
<CreateTeamModal open={showCreateTeamModal} setOpen={(val) => setShowCreateTeamModal(val)} />
|
||||
<UrlShortenerModal
|
||||
open={showLinkShortenerModal}
|
||||
setOpen={(val) => setShowLinkShortenerModal(val)}
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
/>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
@@ -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<UrlValidationState>("default");
|
||||
const [shortUrl, setShortUrl] = useState("");
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<UrlShortenerFormDataProps>({
|
||||
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 (
|
||||
<Modal
|
||||
open={open}
|
||||
setOpen={(v) => {
|
||||
setOpen(v);
|
||||
resetForm();
|
||||
}}
|
||||
noPadding
|
||||
closeOnOutsideClick={false}>
|
||||
<div className="flex h-full flex-col rounded-lg pb-4">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-10 w-10 text-slate-500">
|
||||
<LinkIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">URL shortener</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
Create a short URL to make URL params less obvious.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(shortenUrl)}>
|
||||
<div className="grid w-full space-y-2 rounded-lg px-6 py-4">
|
||||
<Label>Paste URL</Label>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={`${surveyBaseUrl}...`}
|
||||
className={clsx(
|
||||
"col-span-5",
|
||||
urlValidationState === "valid"
|
||||
? "border-green-500 bg-green-50"
|
||||
: urlValidationState === "invalid"
|
||||
? "border-red-200 bg-red-50"
|
||||
: urlValidationState === "default"
|
||||
? "border-slate-200"
|
||||
: "bg-white"
|
||||
)}
|
||||
{...register("url", {
|
||||
required: true,
|
||||
})}
|
||||
onBlur={handleUrlValidation}
|
||||
/>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
className="col-span-1 text-center"
|
||||
type="submit"
|
||||
loading={isSubmitting}>
|
||||
Shorten
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className="grid w-full space-y-2 rounded-lg px-6 py-4">
|
||||
<Label>Short URL</Label>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<span
|
||||
className="col-span-5 cursor-pointer rounded-md border border-slate-300 bg-slate-100 px-3 py-2 text-sm text-slate-700"
|
||||
onClick={() => {
|
||||
if (shortUrl) {
|
||||
copyShortUrlToClipboard();
|
||||
}
|
||||
}}>
|
||||
{shortUrl}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="col-span-1 justify-center"
|
||||
type="button"
|
||||
onClick={() => copyShortUrlToClipboard()}>
|
||||
<span>Copy</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
10
apps/web/app/i/[shortUrlId]/loading.tsx
Normal file
10
apps/web/app/i/[shortUrlId]/loading.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex h-1/2 w-1/4 flex-col">
|
||||
<div className="ph-no-capture h-16 w-1/3 animate-pulse rounded-lg bg-gray-200 font-medium text-slate-900"></div>
|
||||
<div className="ph-no-capture mt-4 h-full animate-pulse rounded-lg bg-gray-200 text-slate-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
apps/web/app/i/[shortUrlId]/page.tsx
Normal file
29
apps/web/app/i/[shortUrlId]/page.tsx
Normal file
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
DateRange,
|
||||
SelectedFilterValue,
|
||||
} from "@/app/(app)/environments/[environmentId]/ResponseFilterContext";
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import {
|
||||
OptionsType,
|
||||
QuestionOptions,
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
72
packages/lib/shortUrl/service.ts
Normal file
72
packages/lib/shortUrl/service.ts
Normal file
@@ -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<TShortUrl> => {
|
||||
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<TShortUrl | null> => {
|
||||
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<TShortUrl | null> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
14
packages/types/v1/shortUrl.ts
Normal file
14
packages/types/v1/shortUrl.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ZShortUrlId = z.string().length(10);
|
||||
|
||||
export type TShortUrlId = z.infer<typeof ZShortUrlId>;
|
||||
|
||||
export const ZShortUrl = z.object({
|
||||
id: ZShortUrlId,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
url: z.string().url(),
|
||||
});
|
||||
|
||||
export type TShortUrl = z.infer<typeof ZShortUrl>;
|
||||
42
pnpm-lock.yaml
generated
42
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user