mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-22 19:39:01 -05:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 590c85d1ca | |||
| 39c99baaac | |||
| 238b2adf3f |
+2
-7
@@ -184,13 +184,8 @@ ENTERPRISE_LICENSE_KEY=
|
|||||||
# Ignore Rate Limiting across the Formbricks app
|
# Ignore Rate Limiting across the Formbricks app
|
||||||
# RATE_LIMITING_DISABLED=1
|
# RATE_LIMITING_DISABLED=1
|
||||||
|
|
||||||
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
# OpenTelemetry URL for tracing
|
||||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
# OPENTELEMETRY_LISTENER_URL=http://localhost:4318/v1/traces
|
||||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
|
||||||
# OTEL_SERVICE_NAME=formbricks
|
|
||||||
# OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development
|
|
||||||
# OTEL_TRACES_SAMPLER=parentbased_traceidratio
|
|
||||||
# OTEL_TRACES_SAMPLER_ARG=1
|
|
||||||
|
|
||||||
# Unsplash API Key
|
# Unsplash API Key
|
||||||
UNSPLASH_ACCESS_KEY=
|
UNSPLASH_ACCESS_KEY=
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo "Updating Chart.yaml with version: ${VERSION}"
|
echo "Updating Chart.yaml with version: ${VERSION}"
|
||||||
yq -i ".version = \"${VERSION}\"" charts/formbricks/Chart.yaml
|
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
|
||||||
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
|
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
|
||||||
|
|
||||||
echo "✅ Successfully updated Chart.yaml"
|
echo "✅ Successfully updated Chart.yaml"
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo "Packaging Helm chart version: ${VERSION}"
|
echo "Packaging Helm chart version: ${VERSION}"
|
||||||
helm package ./charts/formbricks
|
helm package ./helm-chart
|
||||||
|
|
||||||
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
|
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ on:
|
|||||||
merge_group:
|
merge_group:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: read
|
|
||||||
jobs:
|
jobs:
|
||||||
sonarqube:
|
sonarqube:
|
||||||
name: SonarQube
|
name: SonarQube
|
||||||
@@ -51,9 +50,6 @@ jobs:
|
|||||||
pnpm test:coverage
|
pnpm test:coverage
|
||||||
- name: SonarQube Scan
|
- name: SonarQube Scan
|
||||||
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
|
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
|
||||||
with:
|
|
||||||
args: >
|
|
||||||
-Dsonar.verbose=true
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
|||||||
@@ -32,20 +32,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- name: Checkout repository
|
||||||
with:
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
|
||||||
|
|
||||||
- name: Setup Node.js 22.x
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 18
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||||
|
with:
|
||||||
|
version: 9.15.9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Validate translation keys
|
- name: Validate translation keys
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
**/.next/
|
**/.next/
|
||||||
**/out/
|
**/out/
|
||||||
**/build
|
**/build
|
||||||
**/next-env.d.ts
|
|
||||||
|
|
||||||
# node
|
# node
|
||||||
**/dist/
|
**/dist/
|
||||||
@@ -64,5 +63,3 @@ packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdat
|
|||||||
.cursorrules
|
.cursorrules
|
||||||
i18n.cache
|
i18n.cache
|
||||||
stats.html
|
stats.html
|
||||||
# next-agents-md
|
|
||||||
.next-docs/
|
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ["@formbricks/eslint-config/legacy-next.js"],
|
extends: ["@formbricks/eslint-config/legacy-next.js"],
|
||||||
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
|
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ["locales/*.json"],
|
||||||
|
plugins: ["i18n-json"],
|
||||||
|
rules: {
|
||||||
|
"i18n-json/identical-keys": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
filePath: require("path").join(__dirname, "locales", "en-US.json"),
|
||||||
|
checkExtraKeys: false,
|
||||||
|
checkMissingKeys: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
+18
-26
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24-alpine3.23 AS base
|
FROM node:22-alpine3.22 AS base
|
||||||
|
|
||||||
#
|
#
|
||||||
## step 1: Prune monorepo
|
## step 1: Prune monorepo
|
||||||
@@ -20,7 +20,7 @@ FROM base AS installer
|
|||||||
# Enable corepack and prepare pnpm
|
# Enable corepack and prepare pnpm
|
||||||
RUN npm install --ignore-scripts -g corepack@latest
|
RUN npm install --ignore-scripts -g corepack@latest
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
RUN corepack prepare pnpm@10.28.2 --activate
|
RUN corepack prepare pnpm@9.15.9 --activate
|
||||||
|
|
||||||
# Install necessary build tools and compilers
|
# Install necessary build tools and compilers
|
||||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||||
@@ -69,14 +69,20 @@ RUN --mount=type=secret,id=database_url \
|
|||||||
--mount=type=secret,id=sentry_auth_token \
|
--mount=type=secret,id=sentry_auth_token \
|
||||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||||
|
|
||||||
|
# Extract Prisma version
|
||||||
|
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
|
||||||
|
|
||||||
#
|
#
|
||||||
## step 3: setup production runner
|
## step 3: setup production runner
|
||||||
#
|
#
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
# Update npm to latest, then create user
|
RUN npm install --ignore-scripts -g corepack@latest && \
|
||||||
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
|
corepack enable
|
||||||
RUN npm install --ignore-scripts -g npm@latest \
|
|
||||||
|
RUN apk add --no-cache curl \
|
||||||
|
&& apk add --no-cache supercronic \
|
||||||
|
# && addgroup --system --gid 1001 nodejs \
|
||||||
&& addgroup -S nextjs \
|
&& addgroup -S nextjs \
|
||||||
&& adduser -S -u 1001 -G nextjs nextjs
|
&& adduser -S -u 1001 -G nextjs nextjs
|
||||||
|
|
||||||
@@ -107,13 +113,15 @@ RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./package
|
|||||||
COPY --from=installer /app/packages/database/dist ./packages/database/dist
|
COPY --from=installer /app/packages/database/dist ./packages/database/dist
|
||||||
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
||||||
|
|
||||||
# Copy prisma client packages
|
|
||||||
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||||
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
|
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
|
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
|
||||||
|
|
||||||
|
COPY --from=installer /prisma_version.txt .
|
||||||
|
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||||
|
|
||||||
@@ -126,25 +134,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
|||||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||||
RUN chmod -R 755 ./node_modules/zod
|
RUN chmod -R 755 ./node_modules/zod
|
||||||
|
|
||||||
# Pino loads transport code in worker threads via dynamic require().
|
RUN npm install -g prisma@6
|
||||||
# Next.js file tracing only traces static imports, missing runtime-loaded files
|
|
||||||
# (e.g. pino/lib/transport-stream.js, transport targets).
|
|
||||||
# Copy the full packages to ensure all runtime files are available.
|
|
||||||
COPY --from=installer /app/node_modules/pino ./node_modules/pino
|
|
||||||
RUN chmod -R 755 ./node_modules/pino
|
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/pino-opentelemetry-transport ./node_modules/pino-opentelemetry-transport
|
|
||||||
RUN chmod -R 755 ./node_modules/pino-opentelemetry-transport
|
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
|
|
||||||
RUN chmod -R 755 ./node_modules/pino-abstract-transport
|
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/otlp-logger ./node_modules/otlp-logger
|
|
||||||
RUN chmod -R 755 ./node_modules/otlp-logger
|
|
||||||
|
|
||||||
# Install prisma CLI globally for database migrations and fix permissions for nextjs user
|
|
||||||
RUN npm install --ignore-scripts -g prisma@6 \
|
|
||||||
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma
|
|
||||||
|
|
||||||
# Create a startup script to handle the conditional logic
|
# Create a startup script to handle the conditional logic
|
||||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||||
@@ -154,8 +144,10 @@ EXPOSE 3000
|
|||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
|
# Prepare pnpm as the nextjs user to ensure it's available at runtime
|
||||||
# Prepare volumes for uploads and SAML connections
|
# Prepare volumes for uploads and SAML connections
|
||||||
RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
|
RUN corepack prepare pnpm@9.15.9 --activate && \
|
||||||
|
mkdir -p /home/nextjs/apps/web/uploads/ && \
|
||||||
mkdir -p /home/nextjs/apps/web/saml-connection
|
mkdir -p /home/nextjs/apps/web/saml-connection
|
||||||
|
|
||||||
VOLUME /home/nextjs/apps/web/uploads/
|
VOLUME /home/nextjs/apps/web/uploads/
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ const mockProject: TProject = {
|
|||||||
},
|
},
|
||||||
placement: "bottomRight",
|
placement: "bottomRight",
|
||||||
clickOutsideClose: true,
|
clickOutsideClose: true,
|
||||||
overlay: "none",
|
darkOverlay: false,
|
||||||
environments: [],
|
environments: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
logo: null,
|
logo: null,
|
||||||
|
|||||||
+3
-12
@@ -3,7 +3,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||||
import { previewSurvey } from "@/app/lib/templates";
|
import { previewSurvey } from "@/app/lib/templates";
|
||||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||||
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
|
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
|
||||||
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
||||||
@@ -65,17 +64,10 @@ export const ProjectSettings = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const addProject = async (data: TProjectUpdateInput) => {
|
const addProject = async (data: TProjectUpdateInput) => {
|
||||||
try {
|
try {
|
||||||
// Build the full styling from the chosen brand color so all derived
|
|
||||||
// colours (question, button, input, option, progress, etc.) are persisted.
|
|
||||||
// Without this, only brandColor is saved and the look-and-feel page falls
|
|
||||||
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
|
|
||||||
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
|
|
||||||
|
|
||||||
const createProjectResponse = await createProjectAction({
|
const createProjectResponse = await createProjectAction({
|
||||||
organizationId,
|
organizationId,
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
styling: fullStyling,
|
|
||||||
config: { channel, industry },
|
config: { channel, industry },
|
||||||
teamIds: data.teamIds,
|
teamIds: data.teamIds,
|
||||||
},
|
},
|
||||||
@@ -120,7 +112,6 @@ export const ProjectSettings = ({
|
|||||||
const projectName = form.watch("name");
|
const projectName = form.watch("name");
|
||||||
const logoUrl = form.watch("logo.url");
|
const logoUrl = form.watch("logo.url");
|
||||||
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
|
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
|
||||||
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
|
|
||||||
const { isSubmitting } = form.formState;
|
const { isSubmitting } = form.formState;
|
||||||
|
|
||||||
const organizationTeamsOptions = organizationTeams.map((team) => ({
|
const organizationTeamsOptions = organizationTeams.map((team) => ({
|
||||||
@@ -235,7 +226,7 @@ export const ProjectSettings = ({
|
|||||||
alt="Logo"
|
alt="Logo"
|
||||||
width={256}
|
width={256}
|
||||||
height={56}
|
height={56}
|
||||||
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||||
@@ -244,7 +235,7 @@ export const ProjectSettings = ({
|
|||||||
appUrl={publicDomain}
|
appUrl={publicDomain}
|
||||||
isPreviewMode={true}
|
isPreviewMode={true}
|
||||||
survey={previewSurvey(projectName || "my Product", t)}
|
survey={previewSurvey(projectName || "my Product", t)}
|
||||||
styling={previewStyling}
|
styling={{ brandColor: { light: brandColor } }}
|
||||||
isBrandingEnabled={false}
|
isBrandingEnabled={false}
|
||||||
languageCode="default"
|
languageCode="default"
|
||||||
onFileUpload={async (file) => file.name}
|
onFileUpload={async (file) => file.name}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
@@ -138,7 +138,7 @@ export const getProjectsForSwitcherAction = authenticatedActionClient
|
|||||||
// Need membership for getProjectsByUserId (1 DB query)
|
// Need membership for getProjectsByUserId (1 DB query)
|
||||||
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
|
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
throw new AuthorizationError("Membership not found");
|
throw new Error("Membership not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await getProjectsByUserId(ctx.user.id, membership);
|
return await getProjectsByUserId(ctx.user.id, membership);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
// Calculate derived values (no queries)
|
// Calculate derived values (no queries)
|
||||||
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
||||||
|
|
||||||
const { features, lastChecked, isPendingDowngrade, active, status } = license;
|
const { features, lastChecked, isPendingDowngrade, active } = license;
|
||||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||||
const isOwnerOrManager = isOwner || isManager;
|
const isOwnerOrManager = isOwner || isManager;
|
||||||
@@ -63,7 +63,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
active={active}
|
active={active}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
locale={user.locale}
|
locale={user.locale}
|
||||||
status={status}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowUpRightIcon,
|
ArrowUpRightIcon,
|
||||||
|
BarChartIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
Cog,
|
Cog,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
PanelLeftCloseIcon,
|
PanelLeftCloseIcon,
|
||||||
PanelLeftOpenIcon,
|
PanelLeftOpenIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
|
ShapesIcon,
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -99,7 +101,7 @@ export const MainNavigation = ({
|
|||||||
const mainNavigation = useMemo(
|
const mainNavigation = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
name: t("common.surveys"),
|
name: "Ask",
|
||||||
href: `/environments/${environment.id}/surveys`,
|
href: `/environments/${environment.id}/surveys`,
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
isActive: pathname?.includes("/surveys"),
|
isActive: pathname?.includes("/surveys"),
|
||||||
@@ -107,15 +109,24 @@ export const MainNavigation = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: `/environments/${environment.id}/contacts`,
|
href: `/environments/${environment.id}/contacts`,
|
||||||
name: t("common.contacts"),
|
name: "Distribute",
|
||||||
icon: UserIcon,
|
icon: UserIcon,
|
||||||
isActive:
|
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
|
||||||
pathname?.includes("/contacts") ||
|
|
||||||
pathname?.includes("/segments") ||
|
|
||||||
pathname?.includes("/attributes"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t("common.configuration"),
|
name: "Unify",
|
||||||
|
href: `/environments/${environment.id}/workspace/unify`,
|
||||||
|
icon: ShapesIcon,
|
||||||
|
isActive: pathname?.includes("/unify") && !pathname?.includes("/analyze"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Analyze",
|
||||||
|
href: `/environments/${environment.id}/workspace/analyze`,
|
||||||
|
icon: BarChartIcon,
|
||||||
|
isActive: pathname?.includes("/workspace/analyze"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Configure",
|
||||||
href: `/environments/${environment.id}/workspace/general`,
|
href: `/environments/${environment.id}/workspace/general`,
|
||||||
icon: Cog,
|
icon: Cog,
|
||||||
isActive: pathname?.includes("/project"),
|
isActive: pathname?.includes("/project"),
|
||||||
@@ -188,7 +199,7 @@ export const MainNavigation = ({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||||
)}>
|
)}>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ export const ProjectBreadcrumb = ({
|
|||||||
label: t("common.tags"),
|
label: t("common.tags"),
|
||||||
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "unify",
|
||||||
|
label: "Unify Feedback",
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/unify`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
|
|||||||
-142
@@ -1,142 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { TFunction } from "i18next";
|
|
||||||
import { RotateCcwIcon } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
|
||||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
|
||||||
import { Badge } from "@/modules/ui/components/badge";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { SettingsCard } from "../../../components/SettingsCard";
|
|
||||||
|
|
||||||
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
|
|
||||||
|
|
||||||
interface EnterpriseLicenseStatusProps {
|
|
||||||
status: LicenseStatus;
|
|
||||||
gracePeriodEnd?: Date;
|
|
||||||
environmentId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getBadgeConfig = (
|
|
||||||
status: LicenseStatus,
|
|
||||||
t: TFunction
|
|
||||||
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
|
|
||||||
switch (status) {
|
|
||||||
case "active":
|
|
||||||
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
|
|
||||||
case "expired":
|
|
||||||
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
|
|
||||||
case "unreachable":
|
|
||||||
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
|
|
||||||
case "invalid_license":
|
|
||||||
return { type: "error", label: t("environments.settings.enterprise.license_status_invalid") };
|
|
||||||
default:
|
|
||||||
return { type: "gray", label: t("environments.settings.enterprise.license_status") };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EnterpriseLicenseStatus = ({
|
|
||||||
status,
|
|
||||||
gracePeriodEnd,
|
|
||||||
environmentId,
|
|
||||||
}: EnterpriseLicenseStatusProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
|
||||||
const [isRechecking, setIsRechecking] = useState(false);
|
|
||||||
|
|
||||||
const handleRecheck = async () => {
|
|
||||||
setIsRechecking(true);
|
|
||||||
try {
|
|
||||||
const result = await recheckLicenseAction({ environmentId });
|
|
||||||
if (result?.serverError) {
|
|
||||||
toast.error(result.serverError || t("environments.settings.enterprise.recheck_license_failed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result?.data) {
|
|
||||||
if (result.data.status === "unreachable") {
|
|
||||||
toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
|
|
||||||
} else if (result.data.status === "invalid_license") {
|
|
||||||
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
|
|
||||||
} else {
|
|
||||||
toast.success(t("environments.settings.enterprise.recheck_license_success"));
|
|
||||||
}
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
toast.error(t("environments.settings.enterprise.recheck_license_failed"));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : t("environments.settings.enterprise.recheck_license_failed")
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsRechecking(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const badgeConfig = getBadgeConfig(status, t);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsCard
|
|
||||||
title={t("environments.settings.enterprise.license_status")}
|
|
||||||
description={t("environments.settings.enterprise.license_status_description")}>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRecheck}
|
|
||||||
disabled={isRechecking}
|
|
||||||
className="shrink-0">
|
|
||||||
{isRechecking ? (
|
|
||||||
<>
|
|
||||||
<RotateCcwIcon className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{t("environments.settings.enterprise.rechecking")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
|
||||||
{t("environments.settings.enterprise.recheck_license")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{status === "unreachable" && gracePeriodEnd && (
|
|
||||||
<Alert variant="warning" size="small">
|
|
||||||
<AlertDescription className="overflow-visible whitespace-normal">
|
|
||||||
{t("environments.settings.enterprise.license_unreachable_grace_period", {
|
|
||||||
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
}),
|
|
||||||
})}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{status === "invalid_license" && (
|
|
||||||
<Alert variant="error" size="small">
|
|
||||||
<AlertDescription className="overflow-visible whitespace-normal">
|
|
||||||
{t("environments.settings.enterprise.license_invalid_description")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
|
|
||||||
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
|
|
||||||
<a
|
|
||||||
className="font-medium text-slate-700 underline hover:text-slate-900"
|
|
||||||
href="mailto:hola@formbricks.com">
|
|
||||||
hola@formbricks.com
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+28
-17
@@ -2,10 +2,9 @@ import { CheckIcon } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||||
import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
|
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
@@ -26,8 +25,7 @@ const Page = async (props) => {
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const licenseState = await getEnterpriseLicense();
|
const { active: isEnterpriseEdition } = await getEnterpriseLicense();
|
||||||
const hasLicense = licenseState.status !== "no-license";
|
|
||||||
|
|
||||||
const paidFeatures = [
|
const paidFeatures = [
|
||||||
{
|
{
|
||||||
@@ -92,22 +90,35 @@ const Page = async (props) => {
|
|||||||
activeId="enterprise"
|
activeId="enterprise"
|
||||||
/>
|
/>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
{hasLicense ? (
|
{isEnterpriseEdition ? (
|
||||||
<EnterpriseLicenseStatus
|
<div>
|
||||||
status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"}
|
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
|
||||||
gracePeriodEnd={
|
<div className="space-y-4 p-8">
|
||||||
licenseState.status === "unreachable"
|
<div className="flex items-center gap-x-2">
|
||||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
|
||||||
: undefined
|
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
|
||||||
}
|
</div>
|
||||||
environmentId={params.environmentId}
|
<p className="text-slate-800">
|
||||||
/>
|
{t(
|
||||||
|
"environments.settings.enterprise.your_enterprise_license_is_active_all_features_unlocked"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
|
||||||
|
<a className="font-semibold underline" href="mailto:hola@formbricks.com">
|
||||||
|
hola@formbricks.com
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 1024 1024"
|
viewBox="0 0 1024 1024"
|
||||||
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||||
aria-hidden="true">
|
aria-hidden="true">
|
||||||
<circle
|
<circle
|
||||||
cx={512}
|
cx={512}
|
||||||
@@ -142,8 +153,8 @@ const Page = async (props) => {
|
|||||||
{t("environments.settings.enterprise.enterprise_features")}
|
{t("environments.settings.enterprise.enterprise_features")}
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="my-4 space-y-4">
|
<ul className="my-4 space-y-4">
|
||||||
{paidFeatures.map((feature) => (
|
{paidFeatures.map((feature, index) => (
|
||||||
<li key={feature.title} className="flex items-center">
|
<li key={index} className="flex items-center">
|
||||||
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
|
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
|
||||||
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
|
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-7
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
@@ -33,12 +32,7 @@ export const DeleteOrganization = ({
|
|||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await deleteOrganizationAction({ organizationId: organization.id });
|
await deleteOrganizationAction({ organizationId: organization.id });
|
||||||
if (result?.serverError) {
|
|
||||||
toast.error(getFormattedErrorMessage(result));
|
|
||||||
setIsDeleting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success(t("environments.settings.general.organization_deleted_successfully"));
|
toast.success(t("environments.settings.general.organization_deleted_successfully"));
|
||||||
if (typeof localStorage !== "undefined") {
|
if (typeof localStorage !== "undefined") {
|
||||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||||
|
|||||||
-9
@@ -316,14 +316,6 @@ export const generateResponseTableColumns = (
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const responseIdColumn: ColumnDef<TResponseTableData> = {
|
|
||||||
accessorKey: "responseId",
|
|
||||||
header: () => <div className="gap-x-1.5">{t("common.response_id")}</div>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return <IdBadge id={row.original.responseId} />;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const quotasColumn: ColumnDef<TResponseTableData> = {
|
const quotasColumn: ColumnDef<TResponseTableData> = {
|
||||||
accessorKey: "quota",
|
accessorKey: "quota",
|
||||||
header: t("common.quota"),
|
header: t("common.quota"),
|
||||||
@@ -422,7 +414,6 @@ export const generateResponseTableColumns = (
|
|||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
personColumn,
|
personColumn,
|
||||||
singleUseIdColumn,
|
singleUseIdColumn,
|
||||||
responseIdColumn,
|
|
||||||
dateColumn,
|
dateColumn,
|
||||||
...(showQuotasColumn ? [quotasColumn] : []),
|
...(showQuotasColumn ? [quotasColumn] : []),
|
||||||
statusColumn,
|
statusColumn,
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surv
|
|||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { getOriginalFileNameFromUrl } from "@/modules/storage/url-helpers";
|
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { CreateDashboardModal } from "./create-dashboard-modal";
|
||||||
|
import { DashboardsTable } from "./dashboards-table";
|
||||||
|
import { TDashboard } from "./types";
|
||||||
|
|
||||||
|
interface AnalyzeSectionProps {
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyzeSection({ environmentId }: AnalyzeSectionProps) {
|
||||||
|
const [dashboards, setDashboards] = useState<TDashboard[]>([]);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleCreateDashboard = (dashboard: TDashboard) => {
|
||||||
|
setDashboards((prev) => [dashboard, ...prev]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDashboardClick = (dashboard: TDashboard) => {
|
||||||
|
// TODO: Navigate to dashboard detail view
|
||||||
|
console.log("Dashboard clicked:", dashboard);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<PageHeader
|
||||||
|
pageTitle="Analyze"
|
||||||
|
cta={
|
||||||
|
<CreateDashboardModal
|
||||||
|
open={isCreateModalOpen}
|
||||||
|
onOpenChange={setIsCreateModalOpen}
|
||||||
|
onCreateDashboard={handleCreateDashboard}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<DashboardsTable dashboards={dashboards} onDashboardClick={handleDashboardClick} />
|
||||||
|
</div>
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
import { TDashboard } from "./types";
|
||||||
|
|
||||||
|
interface CreateDashboardModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onCreateDashboard: (dashboard: TDashboard) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateDashboardModal({ open, onOpenChange, onCreateDashboard }: CreateDashboardModalProps) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
const newDashboard: TDashboard = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
widgetCount: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
onCreateDashboard(newDashboard);
|
||||||
|
resetForm();
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
if (!newOpen) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
onOpenChange(newOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => onOpenChange(true)} size="sm">
|
||||||
|
Create dashboard
|
||||||
|
<PlusIcon className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Dashboard</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new dashboard to visualize and analyze your unified feedback data.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dashboardName">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="dashboardName"
|
||||||
|
placeholder="e.g., Product Feedback Overview"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dashboardDescription">Description (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="dashboardDescription"
|
||||||
|
placeholder="e.g., Weekly overview of customer feedback trends"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={!name.trim()}>
|
||||||
|
Create dashboard
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { LayoutDashboardIcon } from "lucide-react";
|
||||||
|
import { TDashboard } from "./types";
|
||||||
|
|
||||||
|
interface DashboardsTableProps {
|
||||||
|
dashboards: TDashboard[];
|
||||||
|
onDashboardClick: (dashboard: TDashboard) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardsTable({ dashboards, onDashboardClick }: DashboardsTableProps) {
|
||||||
|
if (dashboards.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-xl border border-slate-200 bg-white py-16">
|
||||||
|
<LayoutDashboardIcon className="h-12 w-12 text-slate-300" />
|
||||||
|
<p className="mt-4 text-sm font-medium text-slate-600">No dashboards yet</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">Create your first dashboard to start analyzing feedback</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white">
|
||||||
|
{/* Table Header */}
|
||||||
|
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||||
|
<div className="col-span-5 pl-6">Name</div>
|
||||||
|
<div className="col-span-3 hidden text-center sm:block">Widgets</div>
|
||||||
|
<div className="col-span-2 hidden text-center sm:block">Updated</div>
|
||||||
|
<div className="col-span-2 hidden pr-6 text-right sm:block">Created</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Rows */}
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{dashboards.map((dashboard) => (
|
||||||
|
<div
|
||||||
|
key={dashboard.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="grid h-16 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
|
||||||
|
onClick={() => onDashboardClick(dashboard)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
onDashboardClick(dashboard);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{/* Name Column */}
|
||||||
|
<div className="col-span-5 flex items-center gap-3 pl-4">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-slate-100">
|
||||||
|
<LayoutDashboardIcon className="h-4 w-4 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-slate-900">{dashboard.name}</span>
|
||||||
|
{dashboard.description && (
|
||||||
|
<span className="truncate text-xs text-slate-500">{dashboard.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Widgets Column */}
|
||||||
|
<div className="col-span-3 hidden items-center justify-center text-sm text-slate-600 sm:flex">
|
||||||
|
{dashboard.widgetCount} {dashboard.widgetCount === 1 ? "widget" : "widgets"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Updated Column */}
|
||||||
|
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||||
|
{formatDistanceToNow(dashboard.updatedAt, { addSuffix: true })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Created Column */}
|
||||||
|
<div className="col-span-2 hidden items-center justify-end pr-4 text-sm text-slate-500 sm:flex">
|
||||||
|
{formatDistanceToNow(dashboard.createdAt, { addSuffix: true })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { AnalyzeSection } from "./AnalyzeSection";
|
||||||
|
export { CreateDashboardModal } from "./create-dashboard-modal";
|
||||||
|
export { DashboardsTable } from "./dashboards-table";
|
||||||
|
export type { TDashboard } from "./types";
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface TDashboard {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
widgetCount: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
import { AnalyzeSection } from "./components";
|
||||||
|
|
||||||
|
export default async function AnalyzePage(props: { params: Promise<{ environmentId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
return <AnalyzeSection environmentId={params.environmentId} />;
|
||||||
|
}
|
||||||
+2
-14
@@ -21,7 +21,6 @@ import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[envir
|
|||||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
|
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
|
||||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||||
@@ -269,14 +268,7 @@ export const AddIntegrationModal = ({
|
|||||||
airtableIntegrationData.config?.data.push(integrationData);
|
airtableIntegrationData.config?.data.push(integrationData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createOrUpdateIntegrationAction({
|
await createOrUpdateIntegrationAction({ environmentId, integrationData: airtableIntegrationData });
|
||||||
environmentId,
|
|
||||||
integrationData: airtableIntegrationData,
|
|
||||||
});
|
|
||||||
if (result?.serverError) {
|
|
||||||
toast.error(getFormattedErrorMessage(result));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||||
} else {
|
} else {
|
||||||
@@ -312,11 +304,7 @@ export const AddIntegrationModal = ({
|
|||||||
const integrationData = structuredClone(airtableIntegrationData);
|
const integrationData = structuredClone(airtableIntegrationData);
|
||||||
integrationData.config.data.splice(index, 1);
|
integrationData.config.data.splice(index, 1);
|
||||||
|
|
||||||
const result = await createOrUpdateIntegrationAction({ environmentId, integrationData });
|
await createOrUpdateIntegrationAction({ environmentId, integrationData });
|
||||||
if (result?.serverError) {
|
|
||||||
toast.error(getFormattedErrorMessage(result));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleClose();
|
handleClose();
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
|
|||||||
+2
-16
@@ -165,14 +165,7 @@ export const AddIntegrationModal = ({
|
|||||||
// create action
|
// create action
|
||||||
googleSheetIntegrationData.config.data.push(integrationData);
|
googleSheetIntegrationData.config.data.push(integrationData);
|
||||||
}
|
}
|
||||||
const result = await createOrUpdateIntegrationAction({
|
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
|
||||||
environmentId,
|
|
||||||
integrationData: googleSheetIntegrationData,
|
|
||||||
});
|
|
||||||
if (result?.serverError) {
|
|
||||||
toast.error(getFormattedErrorMessage(result));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedIntegration) {
|
if (selectedIntegration) {
|
||||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||||
} else {
|
} else {
|
||||||
@@ -212,14 +205,7 @@ export const AddIntegrationModal = ({
|
|||||||
googleSheetIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
googleSheetIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
||||||
try {
|
try {
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
const result = await createOrUpdateIntegrationAction({
|
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
|
||||||
environmentId,
|
|
||||||
integrationData: googleSheetIntegrationData,
|
|
||||||
});
|
|
||||||
if (result?.serverError) {
|
|
||||||
toast.error(getFormattedErrorMessage(result));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+2
-17
@@ -22,7 +22,6 @@ import {
|
|||||||
createEmptyMapping,
|
createEmptyMapping,
|
||||||
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
|
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
|
||||||
import NotionLogo from "@/images/notion.png";
|
import NotionLogo from "@/images/notion.png";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -218,14 +217,7 @@ export const AddIntegrationModal = ({
|
|||||||
notionIntegrationData.config.data.push(integrationData);
|
notionIntegrationData.config.data.push(integrationData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createOrUpdateIntegrationAction({
|
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
|
||||||
environmentId,
|
|
||||||
integrationData: notionIntegrationData,
|
|
||||||
});
|
|
||||||
if (result?.serverError) {
|
|
||||||
toast.error(getFormattedErrorMessage(result));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedIntegration) {
|
if (selectedIntegration) {
|
||||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||||
} else {
|
} else {
|
||||||
@@ -244,14 +236,7 @@ export const AddIntegrationModal = ({
|
|||||||
notionIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
notionIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
||||||
try {
|
try {
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
const result = await createOrUpdateIntegrationAction({
|
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
|
||||||
environmentId,
|
|
||||||
integrationData: notionIntegrationData,
|
|
||||||
});
|
|
||||||
if (result?.serverError) {
|
|
||||||
toast.error(getFormattedErrorMessage(result));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+2
-17
@@ -17,7 +17,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
|||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import SlackLogo from "@/images/slacklogo.png";
|
import SlackLogo from "@/images/slacklogo.png";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||||
@@ -145,14 +144,7 @@ export const AddChannelMappingModal = ({
|
|||||||
// create action
|
// create action
|
||||||
slackIntegrationData.config.data.push(integrationData);
|
slackIntegrationData.config.data.push(integrationData);
|
||||||
}
|
}
|
||||||
const result = await createOrUpdateIntegrationAction({
|
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
|
||||||
environmentId,
|
|
||||||
integrationData: slackIntegrationData,
|
|
||||||
});
|
|
||||||
if (result?.serverError) {
|
|
||||||
toast.error(getFormattedErrorMessage(result));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedIntegration) {
|
if (selectedIntegration) {
|
||||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||||
} else {
|
} else {
|
||||||
@@ -189,14 +181,7 @@ export const AddChannelMappingModal = ({
|
|||||||
slackIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
slackIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
||||||
try {
|
try {
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
const result = await createOrUpdateIntegrationAction({
|
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
|
||||||
environmentId,
|
|
||||||
integrationData: slackIntegrationData,
|
|
||||||
});
|
|
||||||
if (result?.serverError) {
|
|
||||||
toast.error(getFormattedErrorMessage(result));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||||
|
|
||||||
|
interface UnifyConfigNavigationProps {
|
||||||
|
environmentId: string;
|
||||||
|
activeId?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnifyConfigNavigation = ({
|
||||||
|
environmentId,
|
||||||
|
activeId: activeIdProp,
|
||||||
|
loading,
|
||||||
|
}: UnifyConfigNavigationProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const activeId =
|
||||||
|
activeIdProp ??
|
||||||
|
(pathname?.includes("/unify/sources")
|
||||||
|
? "sources"
|
||||||
|
: pathname?.includes("/unify/knowledge")
|
||||||
|
? "knowledge"
|
||||||
|
: pathname?.includes("/unify/taxonomy")
|
||||||
|
? "taxonomy"
|
||||||
|
: "controls");
|
||||||
|
|
||||||
|
const baseHref = `/environments/${environmentId}/workspace/unify`;
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ id: "controls", label: "Controls", href: `${baseHref}/controls` },
|
||||||
|
{ id: "sources", label: "Sources", href: `${baseHref}/sources` },
|
||||||
|
{ id: "knowledge", label: "Knowledge", href: `${baseHref}/knowledge` },
|
||||||
|
{ id: "taxonomy", label: "Taxonomy", href: `${baseHref}/taxonomy` },
|
||||||
|
];
|
||||||
|
|
||||||
|
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||||
|
};
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||||
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/modules/ui/components/select";
|
||||||
|
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||||
|
|
||||||
|
// Common languages for the base language selector
|
||||||
|
const COMMON_LANGUAGES = [
|
||||||
|
{ code: "en", label: "English" },
|
||||||
|
{ code: "de", label: "German" },
|
||||||
|
{ code: "fr", label: "French" },
|
||||||
|
{ code: "es", label: "Spanish" },
|
||||||
|
{ code: "pt", label: "Portuguese" },
|
||||||
|
{ code: "it", label: "Italian" },
|
||||||
|
{ code: "nl", label: "Dutch" },
|
||||||
|
{ code: "pl", label: "Polish" },
|
||||||
|
{ code: "ru", label: "Russian" },
|
||||||
|
{ code: "ja", label: "Japanese" },
|
||||||
|
{ code: "ko", label: "Korean" },
|
||||||
|
{ code: "zh-Hans", label: "Chinese (Simplified)" },
|
||||||
|
{ code: "zh-Hant", label: "Chinese (Traditional)" },
|
||||||
|
{ code: "ar", label: "Arabic" },
|
||||||
|
{ code: "hi", label: "Hindi" },
|
||||||
|
{ code: "tr", label: "Turkish" },
|
||||||
|
{ code: "sv", label: "Swedish" },
|
||||||
|
{ code: "no", label: "Norwegian" },
|
||||||
|
{ code: "da", label: "Danish" },
|
||||||
|
{ code: "fi", label: "Finnish" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ControlsSectionProps {
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ControlsSection({ environmentId }: ControlsSectionProps) {
|
||||||
|
const [baseLanguage, setBaseLanguage] = useState("en");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<PageHeader pageTitle="Unify Feedback">
|
||||||
|
<UnifyConfigNavigation environmentId={environmentId} />
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<SettingsCard
|
||||||
|
title="Feedback Controls"
|
||||||
|
description="Configure how feedback is processed and consolidated across all sources.">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Base Language Setting */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="baseLanguage">Base Language</Label>
|
||||||
|
<Badge text="AI" type="gray" size="tiny" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
All feedback will be consolidated and analyzed in this language. Feedback in other languages
|
||||||
|
will be automatically translated.
|
||||||
|
</p>
|
||||||
|
<div className="w-64">
|
||||||
|
<Select value={baseLanguage} onValueChange={setBaseLanguage}>
|
||||||
|
<SelectTrigger id="baseLanguage">
|
||||||
|
<SelectValue placeholder="Select a language" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{COMMON_LANGUAGES.map((lang) => (
|
||||||
|
<SelectItem key={lang.code} value={lang.code}>
|
||||||
|
{lang.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { ControlsSection } from "./ControlsSection";
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
import { ControlsSection } from "./components";
|
||||||
|
|
||||||
|
export default async function UnifyControlsPage(props: { params: Promise<{ environmentId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
return <ControlsSection environmentId={params.environmentId} />;
|
||||||
|
}
|
||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FileTextIcon, LinkIcon, PlusIcon, StickyNoteIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import type { TAllowedFileExtension } from "@formbricks/types/storage";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||||
|
import type { KnowledgeItem } from "../types";
|
||||||
|
|
||||||
|
const DOC_EXTENSIONS: TAllowedFileExtension[] = ["pdf", "doc", "docx", "txt", "csv"];
|
||||||
|
const MAX_DOC_SIZE_MB = 5;
|
||||||
|
|
||||||
|
interface AddKnowledgeModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onAdd: (item: KnowledgeItem) => void;
|
||||||
|
environmentId: string;
|
||||||
|
isStorageConfigured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddKnowledgeModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onAdd,
|
||||||
|
environmentId,
|
||||||
|
isStorageConfigured,
|
||||||
|
}: AddKnowledgeModalProps) {
|
||||||
|
const [linkUrl, setLinkUrl] = useState("");
|
||||||
|
const [linkTitle, setLinkTitle] = useState("");
|
||||||
|
const [noteContent, setNoteContent] = useState("");
|
||||||
|
const [uploadedDocUrl, setUploadedDocUrl] = useState<string | null>(null);
|
||||||
|
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setLinkUrl("");
|
||||||
|
setLinkTitle("");
|
||||||
|
setNoteContent("");
|
||||||
|
setUploadedDocUrl(null);
|
||||||
|
setUploadedFileName(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocUpload = async (files: File[]) => {
|
||||||
|
if (!isStorageConfigured) {
|
||||||
|
toast.error("File storage is not configured.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
setIsUploading(true);
|
||||||
|
setUploadedDocUrl(null);
|
||||||
|
setUploadedFileName(null);
|
||||||
|
const result = await handleFileUpload(file, environmentId, DOC_EXTENSIONS);
|
||||||
|
setIsUploading(false);
|
||||||
|
if (result.error) {
|
||||||
|
toast.error("Upload failed. Please try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploadedDocUrl(result.url);
|
||||||
|
setUploadedFileName(file.name);
|
||||||
|
toast.success("Document uploaded. Click Add to save.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddLink = () => {
|
||||||
|
if (!linkUrl.trim()) {
|
||||||
|
toast.error("Please enter a URL.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
onAdd({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: "link",
|
||||||
|
title: linkTitle.trim() || undefined,
|
||||||
|
url: linkUrl.trim(),
|
||||||
|
size: linkUrl.trim().length * 100, // Simulated size for links
|
||||||
|
createdAt: now,
|
||||||
|
indexedAt: now, // Links are indexed immediately
|
||||||
|
});
|
||||||
|
resetForm();
|
||||||
|
onOpenChange(false);
|
||||||
|
toast.success("Link added.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNote = () => {
|
||||||
|
if (!noteContent.trim()) {
|
||||||
|
toast.error("Please enter some text.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
onAdd({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: "note",
|
||||||
|
content: noteContent.trim(),
|
||||||
|
size: new Blob([noteContent.trim()]).size,
|
||||||
|
createdAt: now,
|
||||||
|
indexedAt: now, // Notes are indexed immediately
|
||||||
|
});
|
||||||
|
resetForm();
|
||||||
|
onOpenChange(false);
|
||||||
|
toast.success("Note added.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddFile = () => {
|
||||||
|
if (!uploadedDocUrl) {
|
||||||
|
toast.error("Please upload a document first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
onAdd({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: "file",
|
||||||
|
title: uploadedFileName ?? undefined,
|
||||||
|
fileUrl: uploadedDocUrl,
|
||||||
|
fileName: uploadedFileName ?? undefined,
|
||||||
|
size: Math.floor(Math.random() * 500000) + 10000, // Simulated file size (10KB - 500KB)
|
||||||
|
createdAt: now,
|
||||||
|
indexedAt: undefined, // Files take time to index - will show as "Pending"
|
||||||
|
});
|
||||||
|
resetForm();
|
||||||
|
onOpenChange(false);
|
||||||
|
toast.success("Document added.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||||
|
if (files.length) handleDocUpload(files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => onOpenChange(true)} size="sm">
|
||||||
|
Add knowledge
|
||||||
|
<PlusIcon className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) resetForm();
|
||||||
|
onOpenChange(o);
|
||||||
|
}}>
|
||||||
|
<DialogContent className="sm:max-w-lg" disableCloseOnOutsideClick>
|
||||||
|
<DialogHeader>
|
||||||
|
<PlusIcon className="size-5 text-slate-600" />
|
||||||
|
<DialogTitle>Add knowledge</DialogTitle>
|
||||||
|
<DialogDescription>Add knowledge via a link, document upload, or a text note.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody>
|
||||||
|
<Tabs defaultValue="link" className="w-full">
|
||||||
|
<TabsList width="fill" className="mb-4 w-full">
|
||||||
|
<TabsTrigger value="link" icon={<LinkIcon className="size-4" />}>
|
||||||
|
Link
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="upload" icon={<FileTextIcon className="size-4" />}>
|
||||||
|
Upload doc
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="note" icon={<StickyNoteIcon className="size-4" />}>
|
||||||
|
Note
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="link" className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="link-title">Title (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="link-title"
|
||||||
|
placeholder="e.g. Product docs"
|
||||||
|
value={linkTitle}
|
||||||
|
onChange={(e) => setLinkTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="link-url">URL</Label>
|
||||||
|
<Input
|
||||||
|
id="link-url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
value={linkUrl}
|
||||||
|
onChange={(e) => setLinkUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={handleAddLink} size="sm">
|
||||||
|
Add link
|
||||||
|
</Button>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="upload" className="space-y-4">
|
||||||
|
<Uploader
|
||||||
|
id="knowledge-doc-modal"
|
||||||
|
name="knowledge-doc-modal"
|
||||||
|
uploaderClassName="h-32 w-full"
|
||||||
|
allowedFileExtensions={DOC_EXTENSIONS}
|
||||||
|
multiple={false}
|
||||||
|
handleUpload={handleDocUpload}
|
||||||
|
handleDrop={handleDrop}
|
||||||
|
handleDragOver={handleDragOver}
|
||||||
|
isStorageConfigured={isStorageConfigured}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">PDF, Word, text, or CSV. Max {MAX_DOC_SIZE_MB} MB.</p>
|
||||||
|
{isUploading && <p className="text-sm text-slate-600">Uploading…</p>}
|
||||||
|
{uploadedDocUrl && (
|
||||||
|
<p className="text-sm text-slate-700">
|
||||||
|
Ready: <span className="font-medium">{uploadedFileName ?? uploadedDocUrl}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button type="button" onClick={handleAddFile} size="sm" disabled={!uploadedDocUrl}>
|
||||||
|
Add document
|
||||||
|
</Button>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="note" className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="knowledge-note-modal">Note</Label>
|
||||||
|
<textarea
|
||||||
|
id="knowledge-note-modal"
|
||||||
|
rows={5}
|
||||||
|
placeholder="Paste or type knowledge content here..."
|
||||||
|
className={cn(
|
||||||
|
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
)}
|
||||||
|
value={noteContent}
|
||||||
|
onChange={(e) => setNoteContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={handleAddNote} size="sm">
|
||||||
|
Add note
|
||||||
|
</Button>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||||
|
import type { KnowledgeItem } from "../types";
|
||||||
|
import { AddKnowledgeModal } from "./AddKnowledgeModal";
|
||||||
|
import { KnowledgeTable } from "./KnowledgeTable";
|
||||||
|
|
||||||
|
interface KnowledgeSectionProps {
|
||||||
|
environmentId: string;
|
||||||
|
isStorageConfigured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeSection({ environmentId, isStorageConfigured }: KnowledgeSectionProps) {
|
||||||
|
const [items, setItems] = useState<KnowledgeItem[]>([]);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleDeleteItem = (itemId: string) => {
|
||||||
|
setItems((prev) => prev.filter((item) => item.id !== itemId));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<PageHeader
|
||||||
|
pageTitle="Unify Feedback"
|
||||||
|
cta={
|
||||||
|
<AddKnowledgeModal
|
||||||
|
open={modalOpen}
|
||||||
|
onOpenChange={setModalOpen}
|
||||||
|
onAdd={(item) => {
|
||||||
|
setItems((prev) => [...prev, item]);
|
||||||
|
setModalOpen(false);
|
||||||
|
}}
|
||||||
|
environmentId={environmentId}
|
||||||
|
isStorageConfigured={isStorageConfigured}
|
||||||
|
/>
|
||||||
|
}>
|
||||||
|
<UnifyConfigNavigation environmentId={environmentId} />
|
||||||
|
</PageHeader>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<KnowledgeTable items={items} onDeleteItem={handleDeleteItem} />
|
||||||
|
</div>
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
|
import { FileTextIcon, LinkIcon, MoreHorizontalIcon, StickyNoteIcon, TrashIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
|
import { type KnowledgeItem, formatFileSize } from "../types";
|
||||||
|
|
||||||
|
interface KnowledgeTableProps {
|
||||||
|
items: KnowledgeItem[];
|
||||||
|
onDeleteItem?: (itemId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeIcon(type: KnowledgeItem["type"]) {
|
||||||
|
switch (type) {
|
||||||
|
case "link":
|
||||||
|
return <LinkIcon className="size-4 text-slate-500" />;
|
||||||
|
case "file":
|
||||||
|
return <FileTextIcon className="size-4 text-slate-500" />;
|
||||||
|
case "note":
|
||||||
|
return <StickyNoteIcon className="size-4 text-slate-500" />;
|
||||||
|
default:
|
||||||
|
return <FileTextIcon className="size-4 text-slate-500" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeLabel(type: KnowledgeItem["type"]) {
|
||||||
|
switch (type) {
|
||||||
|
case "link":
|
||||||
|
return "Link";
|
||||||
|
case "file":
|
||||||
|
return "Document";
|
||||||
|
case "note":
|
||||||
|
return "Note";
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTitleOrPreview(item: KnowledgeItem): string {
|
||||||
|
if (item.title) return item.title;
|
||||||
|
if (item.type === "link" && item.url) return item.url;
|
||||||
|
if (item.type === "file" && item.fileName) return item.fileName;
|
||||||
|
if (item.type === "note" && item.content) {
|
||||||
|
return item.content.length > 60 ? `${item.content.slice(0, 60)}…` : item.content;
|
||||||
|
}
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeTable({ items, onDeleteItem }: KnowledgeTableProps) {
|
||||||
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||||
|
<div className="col-span-5 pl-6">Name</div>
|
||||||
|
<div className="col-span-2 hidden text-center sm:block">Type</div>
|
||||||
|
<div className="col-span-2 hidden text-center sm:block">Size</div>
|
||||||
|
<div className="col-span-2 hidden text-center sm:block">Indexed At</div>
|
||||||
|
<div className="col-span-1 pr-6 text-right">Actions</div>
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="py-12 text-center text-sm text-slate-400">
|
||||||
|
No knowledge yet. Add a link, upload a document, or add a note.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="grid h-12 min-h-12 grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="col-span-5 flex items-center gap-3 pl-6">
|
||||||
|
{getTypeIcon(item.type)}
|
||||||
|
<div className="flex flex-col overflow-hidden">
|
||||||
|
<div className="truncate text-sm font-medium text-slate-900">{getTitleOrPreview(item)}</div>
|
||||||
|
{item.type === "link" && item.url && item.title && (
|
||||||
|
<div className="truncate text-xs text-slate-500">{item.url}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-600 sm:flex">
|
||||||
|
{getTypeLabel(item.type)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Size */}
|
||||||
|
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||||
|
{formatFileSize(item.size)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Indexed At */}
|
||||||
|
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||||
|
{item.indexedAt ? (
|
||||||
|
<span title={format(item.indexedAt, "PPpp")}>
|
||||||
|
{formatDistanceToNow(item.indexedAt, { addSuffix: true }).replace("about ", "")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400">Pending</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="col-span-1 flex items-center justify-end pr-6">
|
||||||
|
<DropdownMenu
|
||||||
|
open={openMenuId === item.id}
|
||||||
|
onOpenChange={(open) => setOpenMenuId(open ? item.id : null)}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<MoreHorizontalIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-red-600 focus:bg-red-50 focus:text-red-700"
|
||||||
|
onClick={() => {
|
||||||
|
onDeleteItem?.(item.id);
|
||||||
|
setOpenMenuId(null);
|
||||||
|
}}>
|
||||||
|
<TrashIcon className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
import { KnowledgeSection } from "./components/KnowledgeSection";
|
||||||
|
|
||||||
|
export default async function UnifyKnowledgePage(props: { params: Promise<{ environmentId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KnowledgeSection
|
||||||
|
environmentId={params.environmentId}
|
||||||
|
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type KnowledgeItemType = "link" | "note" | "file";
|
||||||
|
|
||||||
|
export interface KnowledgeItem {
|
||||||
|
id: string;
|
||||||
|
type: KnowledgeItemType;
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
content?: string;
|
||||||
|
fileUrl?: string;
|
||||||
|
fileName?: string;
|
||||||
|
size?: number; // Size in bytes
|
||||||
|
createdAt: Date;
|
||||||
|
indexedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format file size to human readable string
|
||||||
|
export function formatFileSize(bytes?: number): string {
|
||||||
|
if (!bytes) return "—";
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function UnifyPage(props: { params: Promise<{ environmentId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
redirect(`/environments/${params.environmentId}/workspace/unify/controls`);
|
||||||
|
}
|
||||||
+396
@@ -0,0 +1,396 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PlusIcon, SparklesIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
import { CsvSourceUI } from "./csv-source-ui";
|
||||||
|
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||||
|
import { MappingUI } from "./mapping-ui";
|
||||||
|
import { SourceTypeSelector } from "./source-type-selector";
|
||||||
|
import {
|
||||||
|
AI_SUGGESTED_MAPPINGS,
|
||||||
|
EMAIL_SOURCE_FIELDS,
|
||||||
|
FEEDBACK_RECORD_FIELDS,
|
||||||
|
MOCK_FORMBRICKS_SURVEYS,
|
||||||
|
SAMPLE_CSV_COLUMNS,
|
||||||
|
SAMPLE_WEBHOOK_PAYLOAD,
|
||||||
|
TCreateSourceStep,
|
||||||
|
TFieldMapping,
|
||||||
|
TSourceConnection,
|
||||||
|
TSourceField,
|
||||||
|
TSourceType,
|
||||||
|
parseCSVColumnsToFields,
|
||||||
|
parsePayloadToFields,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface CreateSourceModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onCreateSource: (source: TSourceConnection) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultSourceName(type: TSourceType): string {
|
||||||
|
switch (type) {
|
||||||
|
case "formbricks":
|
||||||
|
return "Formbricks Survey Connection";
|
||||||
|
case "webhook":
|
||||||
|
return "Webhook Connection";
|
||||||
|
case "email":
|
||||||
|
return "Email Connection";
|
||||||
|
case "csv":
|
||||||
|
return "CSV Import";
|
||||||
|
case "slack":
|
||||||
|
return "Slack Connection";
|
||||||
|
default:
|
||||||
|
return "New Source";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateSourceModal({ open, onOpenChange, onCreateSource }: CreateSourceModalProps) {
|
||||||
|
const [currentStep, setCurrentStep] = useState<TCreateSourceStep>("selectType");
|
||||||
|
const [selectedType, setSelectedType] = useState<TSourceType | null>(null);
|
||||||
|
const [sourceName, setSourceName] = useState("");
|
||||||
|
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||||
|
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||||
|
const [deriveFromAttachments, setDeriveFromAttachments] = useState(false);
|
||||||
|
|
||||||
|
// Formbricks-specific state
|
||||||
|
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
|
||||||
|
const [selectedQuestionIds, setSelectedQuestionIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setCurrentStep("selectType");
|
||||||
|
setSelectedType(null);
|
||||||
|
setSourceName("");
|
||||||
|
setMappings([]);
|
||||||
|
setSourceFields([]);
|
||||||
|
setDeriveFromAttachments(false);
|
||||||
|
setSelectedSurveyId(null);
|
||||||
|
setSelectedQuestionIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
if (!newOpen) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
onOpenChange(newOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextStep = () => {
|
||||||
|
if (currentStep === "selectType" && selectedType && selectedType !== "slack") {
|
||||||
|
if (selectedType === "formbricks") {
|
||||||
|
// For Formbricks, use the survey name if selected
|
||||||
|
const selectedSurvey = MOCK_FORMBRICKS_SURVEYS.find((s) => s.id === selectedSurveyId);
|
||||||
|
setSourceName(
|
||||||
|
selectedSurvey ? `${selectedSurvey.name} Connection` : getDefaultSourceName(selectedType)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSourceName(getDefaultSourceName(selectedType));
|
||||||
|
}
|
||||||
|
setCurrentStep("mapping");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Formbricks handlers
|
||||||
|
const handleSurveySelect = (surveyId: string | null) => {
|
||||||
|
setSelectedSurveyId(surveyId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuestionToggle = (questionId: string) => {
|
||||||
|
setSelectedQuestionIds((prev) =>
|
||||||
|
prev.includes(questionId) ? prev.filter((id) => id !== questionId) : [...prev, questionId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAllQuestions = (surveyId: string) => {
|
||||||
|
const survey = MOCK_FORMBRICKS_SURVEYS.find((s) => s.id === surveyId);
|
||||||
|
if (survey) {
|
||||||
|
setSelectedQuestionIds(survey.questions.map((q) => q.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeselectAllQuestions = () => {
|
||||||
|
setSelectedQuestionIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (currentStep === "mapping") {
|
||||||
|
setCurrentStep("selectType");
|
||||||
|
setMappings([]);
|
||||||
|
setSourceFields([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSource = () => {
|
||||||
|
if (!selectedType || !sourceName.trim()) return;
|
||||||
|
|
||||||
|
// Check if all required fields are mapped
|
||||||
|
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||||
|
const allRequiredMapped = requiredFields.every((field) =>
|
||||||
|
mappings.some((m) => m.targetFieldId === field.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!allRequiredMapped) {
|
||||||
|
// For now, we'll allow creating without all required fields for POC
|
||||||
|
console.warn("Not all required fields are mapped");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSource: TSourceConnection = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: sourceName.trim(),
|
||||||
|
type: selectedType,
|
||||||
|
mappings,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
onCreateSource(newSource);
|
||||||
|
resetForm();
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||||
|
const allRequiredMapped = requiredFields.every((field) =>
|
||||||
|
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Formbricks validation - need survey and at least one question selected
|
||||||
|
const isFormbricksValid =
|
||||||
|
selectedType === "formbricks" && selectedSurveyId && selectedQuestionIds.length > 0;
|
||||||
|
|
||||||
|
// CSV validation - need sourceFields loaded (CSV uploaded or sample loaded)
|
||||||
|
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
|
||||||
|
|
||||||
|
const handleLoadSourceFields = () => {
|
||||||
|
if (!selectedType) return;
|
||||||
|
let fields: TSourceField[];
|
||||||
|
if (selectedType === "webhook") {
|
||||||
|
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||||
|
} else if (selectedType === "email") {
|
||||||
|
fields = EMAIL_SOURCE_FIELDS;
|
||||||
|
} else if (selectedType === "csv") {
|
||||||
|
fields = parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
|
||||||
|
} else {
|
||||||
|
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||||
|
}
|
||||||
|
setSourceFields(fields);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuggestMapping = () => {
|
||||||
|
if (!selectedType) return;
|
||||||
|
const suggestions = AI_SUGGESTED_MAPPINGS[selectedType];
|
||||||
|
if (!suggestions) return;
|
||||||
|
|
||||||
|
const newMappings: TFieldMapping[] = [];
|
||||||
|
|
||||||
|
// Add field mappings from source fields
|
||||||
|
for (const sourceField of sourceFields) {
|
||||||
|
const suggestedTarget = suggestions.fieldMappings[sourceField.id];
|
||||||
|
if (suggestedTarget) {
|
||||||
|
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === suggestedTarget);
|
||||||
|
if (targetExists) {
|
||||||
|
newMappings.push({
|
||||||
|
sourceFieldId: sourceField.id,
|
||||||
|
targetFieldId: suggestedTarget,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add static value mappings
|
||||||
|
for (const [targetFieldId, staticValue] of Object.entries(suggestions.staticValues)) {
|
||||||
|
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === targetFieldId);
|
||||||
|
if (targetExists) {
|
||||||
|
if (!newMappings.some((m) => m.targetFieldId === targetFieldId)) {
|
||||||
|
newMappings.push({
|
||||||
|
targetFieldId,
|
||||||
|
staticValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMappings(newMappings);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLoadButtonLabel = () => {
|
||||||
|
switch (selectedType) {
|
||||||
|
case "webhook":
|
||||||
|
return "Simulate webhook";
|
||||||
|
case "email":
|
||||||
|
return "Load email fields";
|
||||||
|
case "csv":
|
||||||
|
return "Load sample CSV";
|
||||||
|
default:
|
||||||
|
return "Load sample";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => onOpenChange(true)} size="sm">
|
||||||
|
Add source
|
||||||
|
<PlusIcon className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{currentStep === "selectType"
|
||||||
|
? "Add Feedback Source"
|
||||||
|
: selectedType === "formbricks"
|
||||||
|
? "Select Survey & Questions"
|
||||||
|
: selectedType === "csv"
|
||||||
|
? "Import CSV Data"
|
||||||
|
: "Configure Mapping"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{currentStep === "selectType"
|
||||||
|
? "Select the type of feedback source you want to connect."
|
||||||
|
: selectedType === "formbricks"
|
||||||
|
? "Choose which survey questions should create FeedbackRecords."
|
||||||
|
: selectedType === "csv"
|
||||||
|
? "Upload a CSV file or set up automated S3 imports."
|
||||||
|
: "Map source fields to Hub Feedback Record fields."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
{currentStep === "selectType" ? (
|
||||||
|
<SourceTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
|
||||||
|
) : selectedType === "formbricks" ? (
|
||||||
|
/* Formbricks Survey Selector UI */
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sourceName">Source Name</Label>
|
||||||
|
<Input
|
||||||
|
id="sourceName"
|
||||||
|
value={sourceName}
|
||||||
|
onChange={(e) => setSourceName(e.target.value)}
|
||||||
|
placeholder="Enter a name for this source"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<FormbricksSurveySelector
|
||||||
|
selectedSurveyId={selectedSurveyId}
|
||||||
|
selectedQuestionIds={selectedQuestionIds}
|
||||||
|
onSurveySelect={handleSurveySelect}
|
||||||
|
onQuestionToggle={handleQuestionToggle}
|
||||||
|
onSelectAllQuestions={handleSelectAllQuestions}
|
||||||
|
onDeselectAllQuestions={handleDeselectAllQuestions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : selectedType === "csv" ? (
|
||||||
|
/* CSV Upload & S3 Integration UI */
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sourceName">Source Name</Label>
|
||||||
|
<Input
|
||||||
|
id="sourceName"
|
||||||
|
value={sourceName}
|
||||||
|
onChange={(e) => setSourceName(e.target.value)}
|
||||||
|
placeholder="Enter a name for this source"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<CsvSourceUI
|
||||||
|
sourceFields={sourceFields}
|
||||||
|
mappings={mappings}
|
||||||
|
onMappingsChange={setMappings}
|
||||||
|
onSourceFieldsChange={setSourceFields}
|
||||||
|
onLoadSampleCSV={handleLoadSourceFields}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Other source types (webhook, email) - Mapping UI */
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sourceName">Source Name</Label>
|
||||||
|
<Input
|
||||||
|
id="sourceName"
|
||||||
|
value={sourceName}
|
||||||
|
onChange={(e) => setSourceName(e.target.value)}
|
||||||
|
placeholder="Enter a name for this source"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons above scrollable area */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleLoadSourceFields}>
|
||||||
|
{getLoadButtonLabel()}
|
||||||
|
</Button>
|
||||||
|
{sourceFields.length > 0 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
|
||||||
|
<SparklesIcon className="h-4 w-4 text-purple-500" />
|
||||||
|
Suggest mapping
|
||||||
|
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<MappingUI
|
||||||
|
sourceFields={sourceFields}
|
||||||
|
mappings={mappings}
|
||||||
|
onMappingsChange={setMappings}
|
||||||
|
sourceType={selectedType!}
|
||||||
|
deriveFromAttachments={deriveFromAttachments}
|
||||||
|
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{currentStep === "mapping" && (
|
||||||
|
<Button variant="outline" onClick={handleBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{currentStep === "selectType" ? (
|
||||||
|
<Button onClick={handleNextStep} disabled={!selectedType || selectedType === "slack"}>
|
||||||
|
{selectedType === "formbricks"
|
||||||
|
? "Select questions"
|
||||||
|
: selectedType === "csv"
|
||||||
|
? "Configure import"
|
||||||
|
: "Create mapping"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateSource}
|
||||||
|
disabled={
|
||||||
|
!sourceName.trim() ||
|
||||||
|
(selectedType === "formbricks"
|
||||||
|
? !isFormbricksValid
|
||||||
|
: selectedType === "csv"
|
||||||
|
? !isCsvValid
|
||||||
|
: !allRequiredMapped)
|
||||||
|
}>
|
||||||
|
Setup connection
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+327
@@ -0,0 +1,327 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowUpFromLineIcon,
|
||||||
|
CloudIcon,
|
||||||
|
CopyIcon,
|
||||||
|
FolderIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/modules/ui/components/select";
|
||||||
|
import { Switch } from "@/modules/ui/components/switch";
|
||||||
|
import { MappingUI } from "./mapping-ui";
|
||||||
|
import { TFieldMapping, TSourceField } from "./types";
|
||||||
|
|
||||||
|
interface CsvSourceUIProps {
|
||||||
|
sourceFields: TSourceField[];
|
||||||
|
mappings: TFieldMapping[];
|
||||||
|
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||||
|
onSourceFieldsChange: (fields: TSourceField[]) => void;
|
||||||
|
onLoadSampleCSV: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CsvSourceUI({
|
||||||
|
sourceFields,
|
||||||
|
mappings,
|
||||||
|
onMappingsChange,
|
||||||
|
onSourceFieldsChange,
|
||||||
|
onLoadSampleCSV,
|
||||||
|
}: CsvSourceUIProps) {
|
||||||
|
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||||
|
const [csvPreview, setCsvPreview] = useState<string[][]>([]);
|
||||||
|
const [showMapping, setShowMapping] = useState(false);
|
||||||
|
const [s3AutoSync, setS3AutoSync] = useState(false);
|
||||||
|
const [s3Copied, setS3Copied] = useState(false);
|
||||||
|
|
||||||
|
// Mock S3 bucket details
|
||||||
|
const s3BucketName = "formbricks-feedback-imports";
|
||||||
|
const s3Path = `s3://${s3BucketName}/feedback/incoming/`;
|
||||||
|
|
||||||
|
const handleCopyS3Path = () => {
|
||||||
|
navigator.clipboard.writeText(s3Path);
|
||||||
|
setS3Copied(true);
|
||||||
|
setTimeout(() => setS3Copied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target?.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
processCSVFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processCSVFile = (file: File) => {
|
||||||
|
if (!file.name.endsWith(".csv")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCsvFile(file);
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const csv = e.target?.result as string;
|
||||||
|
const lines = csv.split("\n").slice(0, 6); // Preview first 5 rows + header
|
||||||
|
const preview = lines.map((line) => line.split(",").map((cell) => cell.trim()));
|
||||||
|
setCsvPreview(preview);
|
||||||
|
|
||||||
|
// Extract columns and create source fields
|
||||||
|
if (preview.length > 0) {
|
||||||
|
const headers = preview[0];
|
||||||
|
const fields: TSourceField[] = headers.map((header) => ({
|
||||||
|
id: header,
|
||||||
|
name: header,
|
||||||
|
type: "string",
|
||||||
|
sampleValue: preview[1]?.[headers.indexOf(header)] || "",
|
||||||
|
}));
|
||||||
|
onSourceFieldsChange(fields);
|
||||||
|
setShowMapping(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) {
|
||||||
|
processCSVFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadSample = () => {
|
||||||
|
onLoadSampleCSV();
|
||||||
|
setShowMapping(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// If mapping is shown, show the mapping UI
|
||||||
|
if (showMapping && sourceFields.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* File info bar */}
|
||||||
|
{csvFile && (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-green-200 bg-green-50 px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderIcon className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-sm font-medium text-green-800">{csvFile.name}</span>
|
||||||
|
<Badge text={`${csvPreview.length - 1} rows`} type="success" size="tiny" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setCsvFile(null);
|
||||||
|
setCsvPreview([]);
|
||||||
|
setShowMapping(false);
|
||||||
|
onSourceFieldsChange([]);
|
||||||
|
}}>
|
||||||
|
Change file
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CSV Preview Table */}
|
||||||
|
{csvPreview.length > 0 && (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
{csvPreview[0]?.map((header, i) => (
|
||||||
|
<th key={i} className="px-3 py-2 text-left font-medium text-slate-700">
|
||||||
|
{header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{csvPreview.slice(1, 4).map((row, rowIndex) => (
|
||||||
|
<tr key={rowIndex} className="border-t border-slate-100">
|
||||||
|
{row.map((cell, cellIndex) => (
|
||||||
|
<td key={cellIndex} className="px-3 py-2 text-slate-600">
|
||||||
|
{cell || <span className="text-slate-300">—</span>}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{csvPreview.length > 4 && (
|
||||||
|
<div className="border-t border-slate-100 bg-slate-50 px-3 py-1.5 text-center text-xs text-slate-500">
|
||||||
|
Showing 3 of {csvPreview.length - 1} rows
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mapping UI */}
|
||||||
|
<MappingUI
|
||||||
|
sourceFields={sourceFields}
|
||||||
|
mappings={mappings}
|
||||||
|
onMappingsChange={onMappingsChange}
|
||||||
|
sourceType="csv"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload and S3 setup UI
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Manual Upload Section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-slate-700">Upload CSV File</h4>
|
||||||
|
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">
|
||||||
|
<label
|
||||||
|
htmlFor="csv-file-upload"
|
||||||
|
className="flex cursor-pointer flex-col items-center justify-center"
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}>
|
||||||
|
<ArrowUpFromLineIcon className="h-8 w-8 text-slate-400" />
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-400">CSV files only</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="csv-file-upload"
|
||||||
|
accept=".csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button variant="secondary" size="sm" onClick={handleLoadSample}>
|
||||||
|
Load sample CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-px flex-1 bg-slate-200" />
|
||||||
|
<span className="text-xs font-medium uppercase text-slate-400">or</span>
|
||||||
|
<div className="h-px flex-1 bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* S3 Integration Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CloudIcon className="h-5 w-5 text-slate-500" />
|
||||||
|
<h4 className="text-sm font-medium text-slate-700">S3 Bucket Integration</h4>
|
||||||
|
<Badge text="Automated" type="gray" size="tiny" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
||||||
|
<p className="mb-4 text-sm text-slate-600">
|
||||||
|
Drop CSV files into your S3 bucket to automatically import feedback. Files are processed every 15
|
||||||
|
minutes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* S3 Path Display */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Drop zone path</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 rounded bg-slate-100 px-3 py-2 font-mono text-sm text-slate-700">
|
||||||
|
{s3Path}
|
||||||
|
</code>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleCopyS3Path}>
|
||||||
|
<CopyIcon className="h-4 w-4" />
|
||||||
|
{s3Copied ? "Copied!" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* S3 Settings */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">AWS Region</Label>
|
||||||
|
<Select defaultValue="eu-central-1">
|
||||||
|
<SelectTrigger className="bg-white">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="us-east-1">US East (N. Virginia)</SelectItem>
|
||||||
|
<SelectItem value="us-west-2">US West (Oregon)</SelectItem>
|
||||||
|
<SelectItem value="eu-central-1">EU (Frankfurt)</SelectItem>
|
||||||
|
<SelectItem value="eu-west-1">EU (Ireland)</SelectItem>
|
||||||
|
<SelectItem value="ap-southeast-1">Asia Pacific (Singapore)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Processing interval</Label>
|
||||||
|
<Select defaultValue="15">
|
||||||
|
<SelectTrigger className="bg-white">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">Every 5 minutes</SelectItem>
|
||||||
|
<SelectItem value="15">Every 15 minutes</SelectItem>
|
||||||
|
<SelectItem value="30">Every 30 minutes</SelectItem>
|
||||||
|
<SelectItem value="60">Every hour</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-sync toggle */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium text-slate-900">Enable auto-sync</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Automatically process new files dropped in the bucket
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Switch checked={s3AutoSync} onCheckedChange={setS3AutoSync} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IAM Instructions */}
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<SettingsIcon className="mt-0.5 h-4 w-4 text-amber-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-amber-800">IAM Configuration Required</p>
|
||||||
|
<p className="mt-1 text-xs text-amber-700">
|
||||||
|
Add the Formbricks IAM role to your S3 bucket policy to enable access.{" "}
|
||||||
|
<button type="button" className="font-medium underline hover:no-underline">
|
||||||
|
View setup guide →
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Connection */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
|
<RefreshCwIcon className="h-4 w-4" />
|
||||||
|
Test connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+299
@@ -0,0 +1,299 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
FileSpreadsheetIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
MailIcon,
|
||||||
|
MessageSquareIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
WebhookIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
import { MappingUI } from "./mapping-ui";
|
||||||
|
import {
|
||||||
|
AI_SUGGESTED_MAPPINGS,
|
||||||
|
EMAIL_SOURCE_FIELDS,
|
||||||
|
FEEDBACK_RECORD_FIELDS,
|
||||||
|
SAMPLE_CSV_COLUMNS,
|
||||||
|
SAMPLE_WEBHOOK_PAYLOAD,
|
||||||
|
TFieldMapping,
|
||||||
|
TSourceConnection,
|
||||||
|
TSourceField,
|
||||||
|
TSourceType,
|
||||||
|
parseCSVColumnsToFields,
|
||||||
|
parsePayloadToFields,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface EditSourceModalProps {
|
||||||
|
source: TSourceConnection | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onUpdateSource: (source: TSourceConnection) => void;
|
||||||
|
onDeleteSource: (sourceId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceIcon(type: TSourceType) {
|
||||||
|
switch (type) {
|
||||||
|
case "formbricks":
|
||||||
|
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
|
||||||
|
case "webhook":
|
||||||
|
return <WebhookIcon className="h-5 w-5 text-slate-500" />;
|
||||||
|
case "email":
|
||||||
|
return <MailIcon className="h-5 w-5 text-slate-500" />;
|
||||||
|
case "csv":
|
||||||
|
return <FileSpreadsheetIcon className="h-5 w-5 text-slate-500" />;
|
||||||
|
case "slack":
|
||||||
|
return <MessageSquareIcon className="h-5 w-5 text-slate-500" />;
|
||||||
|
default:
|
||||||
|
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceTypeLabel(type: TSourceType) {
|
||||||
|
switch (type) {
|
||||||
|
case "formbricks":
|
||||||
|
return "Formbricks Surveys";
|
||||||
|
case "webhook":
|
||||||
|
return "Webhook";
|
||||||
|
case "email":
|
||||||
|
return "Email";
|
||||||
|
case "csv":
|
||||||
|
return "CSV Import";
|
||||||
|
case "slack":
|
||||||
|
return "Slack Message";
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialSourceFields(type: TSourceType): TSourceField[] {
|
||||||
|
switch (type) {
|
||||||
|
case "webhook":
|
||||||
|
return parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||||
|
case "email":
|
||||||
|
return EMAIL_SOURCE_FIELDS;
|
||||||
|
case "csv":
|
||||||
|
return parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditSourceModal({
|
||||||
|
source,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onUpdateSource,
|
||||||
|
onDeleteSource,
|
||||||
|
}: EditSourceModalProps) {
|
||||||
|
const [sourceName, setSourceName] = useState("");
|
||||||
|
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||||
|
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [deriveFromAttachments, setDeriveFromAttachments] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (source) {
|
||||||
|
setSourceName(source.name);
|
||||||
|
setMappings(source.mappings);
|
||||||
|
setSourceFields(getInitialSourceFields(source.type));
|
||||||
|
setDeriveFromAttachments(false);
|
||||||
|
}
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setSourceName("");
|
||||||
|
setMappings([]);
|
||||||
|
setSourceFields([]);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setDeriveFromAttachments(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
if (!newOpen) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
onOpenChange(newOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateSource = () => {
|
||||||
|
if (!source || !sourceName.trim()) return;
|
||||||
|
|
||||||
|
const updatedSource: TSourceConnection = {
|
||||||
|
...source,
|
||||||
|
name: sourceName.trim(),
|
||||||
|
mappings,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
onUpdateSource(updatedSource);
|
||||||
|
handleOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSource = () => {
|
||||||
|
if (!source) return;
|
||||||
|
onDeleteSource(source.id);
|
||||||
|
handleOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadSourceFields = () => {
|
||||||
|
if (!source) return;
|
||||||
|
let fields: TSourceField[];
|
||||||
|
if (source.type === "webhook") {
|
||||||
|
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||||
|
} else if (source.type === "email") {
|
||||||
|
fields = EMAIL_SOURCE_FIELDS;
|
||||||
|
} else if (source.type === "csv") {
|
||||||
|
fields = parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
|
||||||
|
} else {
|
||||||
|
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||||
|
}
|
||||||
|
setSourceFields(fields);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuggestMapping = () => {
|
||||||
|
if (!source) return;
|
||||||
|
const suggestions = AI_SUGGESTED_MAPPINGS[source.type];
|
||||||
|
if (!suggestions) return;
|
||||||
|
|
||||||
|
const newMappings: TFieldMapping[] = [];
|
||||||
|
|
||||||
|
for (const sourceField of sourceFields) {
|
||||||
|
const suggestedTarget = suggestions.fieldMappings[sourceField.id];
|
||||||
|
if (suggestedTarget) {
|
||||||
|
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === suggestedTarget);
|
||||||
|
if (targetExists) {
|
||||||
|
newMappings.push({
|
||||||
|
sourceFieldId: sourceField.id,
|
||||||
|
targetFieldId: suggestedTarget,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [targetFieldId, staticValue] of Object.entries(suggestions.staticValues)) {
|
||||||
|
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === targetFieldId);
|
||||||
|
if (targetExists) {
|
||||||
|
if (!newMappings.some((m) => m.targetFieldId === targetFieldId)) {
|
||||||
|
newMappings.push({
|
||||||
|
targetFieldId,
|
||||||
|
staticValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMappings(newMappings);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLoadButtonLabel = () => {
|
||||||
|
switch (source?.type) {
|
||||||
|
case "webhook":
|
||||||
|
return "Simulate webhook";
|
||||||
|
case "email":
|
||||||
|
return "Load email fields";
|
||||||
|
case "csv":
|
||||||
|
return "Load sample CSV";
|
||||||
|
default:
|
||||||
|
return "Load sample";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!source) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Source Connection</DialogTitle>
|
||||||
|
<DialogDescription>Update the mapping configuration for this source.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Source Type Display */}
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||||
|
{getSourceIcon(source.type)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-900">{getSourceTypeLabel(source.type)}</p>
|
||||||
|
<p className="text-xs text-slate-500">Source type cannot be changed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="editSourceName">Source Name</Label>
|
||||||
|
<Input
|
||||||
|
id="editSourceName"
|
||||||
|
value={sourceName}
|
||||||
|
onChange={(e) => setSourceName(e.target.value)}
|
||||||
|
placeholder="Enter a name for this source"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons above scrollable area */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleLoadSourceFields}>
|
||||||
|
{getLoadButtonLabel()}
|
||||||
|
</Button>
|
||||||
|
{sourceFields.length > 0 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
|
||||||
|
<SparklesIcon className="h-4 w-4 text-purple-500" />
|
||||||
|
Suggest mapping
|
||||||
|
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mapping UI */}
|
||||||
|
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<MappingUI
|
||||||
|
sourceFields={sourceFields}
|
||||||
|
mappings={mappings}
|
||||||
|
onMappingsChange={setMappings}
|
||||||
|
sourceType={source.type}
|
||||||
|
deriveFromAttachments={deriveFromAttachments}
|
||||||
|
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex justify-between">
|
||||||
|
<div>
|
||||||
|
{showDeleteConfirm ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-red-600">Are you sure?</span>
|
||||||
|
<Button variant="destructive" size="sm" onClick={handleDeleteSource}>
|
||||||
|
Yes, delete
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" onClick={() => setShowDeleteConfirm(true)}>
|
||||||
|
Delete source
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleUpdateSource} disabled={!sourceName.trim()}>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CheckCircle2Icon,
|
||||||
|
CheckIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
CircleIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
MessageSquareTextIcon,
|
||||||
|
StarIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
|
import {
|
||||||
|
MOCK_FORMBRICKS_SURVEYS,
|
||||||
|
TFormbricksSurvey,
|
||||||
|
TFormbricksSurveyQuestion,
|
||||||
|
getQuestionTypeLabel,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface FormbricksSurveySelectorProps {
|
||||||
|
selectedSurveyId: string | null;
|
||||||
|
selectedQuestionIds: string[];
|
||||||
|
onSurveySelect: (surveyId: string | null) => void;
|
||||||
|
onQuestionToggle: (questionId: string) => void;
|
||||||
|
onSelectAllQuestions: (surveyId: string) => void;
|
||||||
|
onDeselectAllQuestions: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuestionIcon(type: TFormbricksSurveyQuestion["type"]) {
|
||||||
|
switch (type) {
|
||||||
|
case "openText":
|
||||||
|
return <MessageSquareTextIcon className="h-4 w-4 text-slate-500" />;
|
||||||
|
case "rating":
|
||||||
|
case "nps":
|
||||||
|
case "csat":
|
||||||
|
return <StarIcon className="h-4 w-4 text-amber-500" />;
|
||||||
|
default:
|
||||||
|
return <FileTextIcon className="h-4 w-4 text-slate-500" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadge(status: TFormbricksSurvey["status"]) {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return <Badge text="Active" type="success" size="tiny" />;
|
||||||
|
case "paused":
|
||||||
|
return <Badge text="Paused" type="warning" size="tiny" />;
|
||||||
|
case "draft":
|
||||||
|
return <Badge text="Draft" type="gray" size="tiny" />;
|
||||||
|
case "completed":
|
||||||
|
return <Badge text="Completed" type="gray" size="tiny" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormbricksSurveySelector({
|
||||||
|
selectedSurveyId,
|
||||||
|
selectedQuestionIds,
|
||||||
|
onSurveySelect,
|
||||||
|
onQuestionToggle,
|
||||||
|
onSelectAllQuestions,
|
||||||
|
onDeselectAllQuestions,
|
||||||
|
}: FormbricksSurveySelectorProps) {
|
||||||
|
const [expandedSurveyId, setExpandedSurveyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selectedSurvey = MOCK_FORMBRICKS_SURVEYS.find((s) => s.id === selectedSurveyId);
|
||||||
|
|
||||||
|
const handleSurveyClick = (survey: TFormbricksSurvey) => {
|
||||||
|
if (selectedSurveyId === survey.id) {
|
||||||
|
// Toggle expand/collapse if already selected
|
||||||
|
setExpandedSurveyId(expandedSurveyId === survey.id ? null : survey.id);
|
||||||
|
} else {
|
||||||
|
// Select the survey and expand it
|
||||||
|
onSurveySelect(survey.id);
|
||||||
|
onDeselectAllQuestions();
|
||||||
|
setExpandedSurveyId(survey.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allQuestionsSelected =
|
||||||
|
selectedSurvey && selectedQuestionIds.length === selectedSurvey.questions.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
{/* Left: Survey List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-slate-700">Select Survey</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{MOCK_FORMBRICKS_SURVEYS.map((survey) => {
|
||||||
|
const isSelected = selectedSurveyId === survey.id;
|
||||||
|
const isExpanded = expandedSurveyId === survey.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={survey.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSurveyClick(survey)}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "border-brand-dark bg-slate-50"
|
||||||
|
: "border-slate-200 bg-white hover:border-slate-300"
|
||||||
|
}`}>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-slate-100">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDownIcon className="h-4 w-4 text-slate-600" />
|
||||||
|
) : (
|
||||||
|
<ChevronRightIcon className="h-4 w-4 text-slate-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-slate-900">{survey.name}</span>
|
||||||
|
{getStatusBadge(survey.status)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{survey.questions.length} questions · {survey.responseCount.toLocaleString()} responses
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isSelected && <CheckCircle2Icon className="text-brand-dark h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Question Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium text-slate-700">Select Questions</h4>
|
||||||
|
{selectedSurvey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
allQuestionsSelected ? onDeselectAllQuestions() : onSelectAllQuestions(selectedSurvey.id)
|
||||||
|
}
|
||||||
|
className="text-xs text-slate-500 hover:text-slate-700">
|
||||||
|
{allQuestionsSelected ? "Deselect all" : "Select all"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedSurvey ? (
|
||||||
|
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||||
|
<p className="text-sm text-slate-500">Select a survey to see its questions</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedSurvey.questions.map((question) => {
|
||||||
|
const isSelected = selectedQuestionIds.includes(question.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={question.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onQuestionToggle(question.id)}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "border-green-300 bg-green-50"
|
||||||
|
: "border-slate-200 bg-white hover:border-slate-300"
|
||||||
|
}`}>
|
||||||
|
<div
|
||||||
|
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||||
|
isSelected ? "bg-green-500 text-white" : "border border-slate-300 bg-white"
|
||||||
|
}`}>
|
||||||
|
{isSelected && <CheckIcon className="h-3 w-3" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">{getQuestionIcon(question.type)}</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-slate-900">{question.headline}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-500">{getQuestionTypeLabel(question.type)}</span>
|
||||||
|
{question.required && (
|
||||||
|
<span className="text-xs text-red-500">
|
||||||
|
<CircleIcon className="inline h-1.5 w-1.5 fill-current" /> Required
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{selectedQuestionIds.length > 0 && (
|
||||||
|
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||||
|
<p className="text-xs text-blue-700">
|
||||||
|
<strong>{selectedQuestionIds.length}</strong> question
|
||||||
|
{selectedQuestionIds.length !== 1 ? "s" : ""} selected. Each response to these questions
|
||||||
|
will create a FeedbackRecord in the Hub.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
export { CreateSourceModal } from "./create-source-modal";
|
||||||
|
export { CsvSourceUI } from "./csv-source-ui";
|
||||||
|
export { EditSourceModal } from "./edit-source-modal";
|
||||||
|
export { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||||
|
export { MappingUI } from "./mapping-ui";
|
||||||
|
export { SourcesSection } from "./sources-page-client";
|
||||||
|
export { SourcesTable } from "./sources-table";
|
||||||
|
export { SourcesTableDataRow } from "./sources-table-data-row";
|
||||||
|
export { SourceTypeSelector } from "./source-type-selector";
|
||||||
|
export * from "./types";
|
||||||
+305
@@ -0,0 +1,305 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||||
|
import { ChevronDownIcon, GripVerticalIcon, PencilIcon, XIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/modules/ui/components/select";
|
||||||
|
import { TFieldMapping, TSourceField, TTargetField } from "./types";
|
||||||
|
|
||||||
|
interface DraggableSourceFieldProps {
|
||||||
|
field: TSourceField;
|
||||||
|
isMapped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DraggableSourceField({ field, isMapped }: DraggableSourceFieldProps) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||||
|
id: field.id,
|
||||||
|
data: field,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = transform
|
||||||
|
? {
|
||||||
|
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
className={`flex cursor-grab items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
|
||||||
|
isDragging
|
||||||
|
? "border-brand-dark bg-slate-100 opacity-50"
|
||||||
|
: isMapped
|
||||||
|
? "border-green-300 bg-green-50 text-green-800"
|
||||||
|
: "border-slate-200 bg-white hover:border-slate-300"
|
||||||
|
}`}>
|
||||||
|
<GripVerticalIcon className="h-4 w-4 text-slate-400" />
|
||||||
|
<div className="flex-1 truncate">
|
||||||
|
<span className="font-medium">{field.name}</span>
|
||||||
|
<span className="ml-2 text-xs text-slate-500">({field.type})</span>
|
||||||
|
</div>
|
||||||
|
{field.sampleValue && (
|
||||||
|
<span className="max-w-24 truncate text-xs text-slate-400">{field.sampleValue}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DroppableTargetFieldProps {
|
||||||
|
field: TTargetField;
|
||||||
|
mappedSourceField: TSourceField | null;
|
||||||
|
mapping: TFieldMapping | null;
|
||||||
|
onRemoveMapping: () => void;
|
||||||
|
onStaticValueChange: (value: string) => void;
|
||||||
|
isOver?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DroppableTargetField({
|
||||||
|
field,
|
||||||
|
mappedSourceField,
|
||||||
|
mapping,
|
||||||
|
onRemoveMapping,
|
||||||
|
onStaticValueChange,
|
||||||
|
isOver,
|
||||||
|
}: DroppableTargetFieldProps) {
|
||||||
|
const { setNodeRef, isOver: isOverCurrent } = useDroppable({
|
||||||
|
id: field.id,
|
||||||
|
data: field,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isEditingStatic, setIsEditingStatic] = useState(false);
|
||||||
|
const [customValue, setCustomValue] = useState("");
|
||||||
|
|
||||||
|
const isActive = isOver || isOverCurrent;
|
||||||
|
const hasMapping = mappedSourceField || mapping?.staticValue;
|
||||||
|
|
||||||
|
// Handle enum field type - show dropdown
|
||||||
|
if (field.type === "enum" && field.enumValues) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
|
||||||
|
mapping?.staticValue ? "border-green-300 bg-green-50" : "border-dashed border-slate-300 bg-slate-50"
|
||||||
|
}`}>
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-slate-900">{field.name}</span>
|
||||||
|
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||||
|
<span className="text-xs text-slate-400">(enum)</span>
|
||||||
|
</div>
|
||||||
|
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
|
||||||
|
<SelectTrigger className="h-8 w-full bg-white">
|
||||||
|
<SelectValue placeholder="Select a value..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{field.enumValues.map((value) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle string fields - allow drag & drop OR static value
|
||||||
|
if (field.type === "string") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "border-brand-dark bg-slate-100"
|
||||||
|
: hasMapping
|
||||||
|
? "border-green-300 bg-green-50"
|
||||||
|
: "border-dashed border-slate-300 bg-slate-50"
|
||||||
|
}`}>
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-slate-900">{field.name}</span>
|
||||||
|
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show mapped source field */}
|
||||||
|
{mappedSourceField && !mapping?.staticValue && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemoveMapping}
|
||||||
|
className="ml-1 rounded p-0.5 hover:bg-green-100">
|
||||||
|
<XIcon className="h-3 w-3 text-green-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show static value */}
|
||||||
|
{mapping?.staticValue && !mappedSourceField && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||||
|
= "{mapping.staticValue}"
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemoveMapping}
|
||||||
|
className="ml-1 rounded p-0.5 hover:bg-blue-100">
|
||||||
|
<XIcon className="h-3 w-3 text-blue-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show input for entering static value when editing */}
|
||||||
|
{isEditingStatic && !hasMapping && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={customValue}
|
||||||
|
onChange={(e) => setCustomValue(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
field.exampleStaticValues ? `e.g., ${field.exampleStaticValues[0]}` : "Enter value..."
|
||||||
|
}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && customValue.trim()) {
|
||||||
|
onStaticValueChange(customValue.trim());
|
||||||
|
setCustomValue("");
|
||||||
|
setIsEditingStatic(false);
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setCustomValue("");
|
||||||
|
setIsEditingStatic(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (customValue.trim()) {
|
||||||
|
onStaticValueChange(customValue.trim());
|
||||||
|
setCustomValue("");
|
||||||
|
}
|
||||||
|
setIsEditingStatic(false);
|
||||||
|
}}
|
||||||
|
className="rounded p-1 text-slate-500 hover:bg-slate-200">
|
||||||
|
<ChevronDownIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show example values as quick select OR drop zone */}
|
||||||
|
{!hasMapping && !isEditingStatic && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
<span className="text-xs text-slate-400">Drop field or</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditingStatic(true)}
|
||||||
|
className="flex items-center gap-1 rounded px-1 py-0.5 text-xs text-slate-500 hover:bg-slate-200">
|
||||||
|
<PencilIcon className="h-3 w-3" />
|
||||||
|
set value
|
||||||
|
</button>
|
||||||
|
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-slate-300">|</span>
|
||||||
|
{field.exampleStaticValues.slice(0, 3).map((val) => (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStaticValueChange(val)}
|
||||||
|
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
|
||||||
|
{val}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get display label for static values
|
||||||
|
const getStaticValueLabel = (value: string) => {
|
||||||
|
if (value === "$now") return "Feedback date";
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default behavior for other field types (timestamp, float64, boolean, jsonb, etc.)
|
||||||
|
const hasDefaultMapping = mappedSourceField || mapping?.staticValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "border-brand-dark bg-slate-100"
|
||||||
|
: hasDefaultMapping
|
||||||
|
? "border-green-300 bg-green-50"
|
||||||
|
: "border-dashed border-slate-300 bg-slate-50"
|
||||||
|
}`}>
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-slate-900">{field.name}</span>
|
||||||
|
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||||
|
<span className="text-xs text-slate-400">({field.type})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show mapped source field */}
|
||||||
|
{mappedSourceField && !mapping?.staticValue && (
|
||||||
|
<div className="mt-1 flex items-center gap-1">
|
||||||
|
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||||
|
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-green-100">
|
||||||
|
<XIcon className="h-3 w-3 text-green-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show static value */}
|
||||||
|
{mapping?.staticValue && !mappedSourceField && (
|
||||||
|
<div className="mt-1 flex items-center gap-1">
|
||||||
|
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||||
|
= {getStaticValueLabel(mapping.staticValue)}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-blue-100">
|
||||||
|
<XIcon className="h-3 w-3 text-blue-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show drop zone with preset options */}
|
||||||
|
{!hasDefaultMapping && (
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||||
|
<span className="text-xs text-slate-400">Drop a field here</span>
|
||||||
|
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-slate-300">|</span>
|
||||||
|
{field.exampleStaticValues.map((val) => (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStaticValueChange(val)}
|
||||||
|
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
|
||||||
|
{getStaticValueLabel(val)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core";
|
||||||
|
import { CopyIcon, MailIcon } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
|
import { Switch } from "@/modules/ui/components/switch";
|
||||||
|
import { DraggableSourceField, DroppableTargetField } from "./mapping-field";
|
||||||
|
import { FEEDBACK_RECORD_FIELDS, TFieldMapping, TSourceField, TSourceType } from "./types";
|
||||||
|
|
||||||
|
interface MappingUIProps {
|
||||||
|
sourceFields: TSourceField[];
|
||||||
|
mappings: TFieldMapping[];
|
||||||
|
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||||
|
sourceType: TSourceType;
|
||||||
|
deriveFromAttachments?: boolean;
|
||||||
|
onDeriveFromAttachmentsChange?: (value: boolean) => void;
|
||||||
|
emailInboxId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MappingUI({
|
||||||
|
sourceFields,
|
||||||
|
mappings,
|
||||||
|
onMappingsChange,
|
||||||
|
sourceType,
|
||||||
|
deriveFromAttachments = false,
|
||||||
|
onDeriveFromAttachmentsChange,
|
||||||
|
emailInboxId,
|
||||||
|
}: MappingUIProps) {
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [emailCopied, setEmailCopied] = useState(false);
|
||||||
|
|
||||||
|
// Generate a stable random email ID if not provided
|
||||||
|
const generatedEmailId = useMemo(() => {
|
||||||
|
if (emailInboxId) return emailInboxId;
|
||||||
|
return `fb-${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
}, [emailInboxId]);
|
||||||
|
|
||||||
|
const inboxEmail = `${generatedEmailId}@inbox.formbricks.com`;
|
||||||
|
|
||||||
|
const handleCopyEmail = () => {
|
||||||
|
navigator.clipboard.writeText(inboxEmail);
|
||||||
|
setEmailCopied(true);
|
||||||
|
setTimeout(() => setEmailCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||||
|
const optionalFields = FEEDBACK_RECORD_FIELDS.filter((f) => !f.required);
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id as string);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
setActiveId(null);
|
||||||
|
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const sourceFieldId = active.id as string;
|
||||||
|
const targetFieldId = over.id as string;
|
||||||
|
|
||||||
|
// Check if this target already has a mapping
|
||||||
|
const existingMapping = mappings.find((m) => m.targetFieldId === targetFieldId);
|
||||||
|
if (existingMapping) {
|
||||||
|
// Remove the existing mapping first
|
||||||
|
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
|
||||||
|
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
|
||||||
|
} else {
|
||||||
|
// Remove any existing mapping for this source field
|
||||||
|
const newMappings = mappings.filter((m) => m.sourceFieldId !== sourceFieldId);
|
||||||
|
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMapping = (targetFieldId: string) => {
|
||||||
|
onMappingsChange(mappings.filter((m) => m.targetFieldId !== targetFieldId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStaticValueChange = (targetFieldId: string, staticValue: string) => {
|
||||||
|
// Remove any existing mapping for this target field
|
||||||
|
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
|
||||||
|
// Add new static value mapping
|
||||||
|
onMappingsChange([...newMappings, { targetFieldId, staticValue }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSourceFieldById = (id: string) => sourceFields.find((f) => f.id === id);
|
||||||
|
const getMappingForTarget = (targetFieldId: string) => {
|
||||||
|
return mappings.find((m) => m.targetFieldId === targetFieldId) ?? null;
|
||||||
|
};
|
||||||
|
const getMappedSourceField = (targetFieldId: string) => {
|
||||||
|
const mapping = getMappingForTarget(targetFieldId);
|
||||||
|
return mapping?.sourceFieldId ? getSourceFieldById(mapping.sourceFieldId) : null;
|
||||||
|
};
|
||||||
|
const isSourceFieldMapped = (sourceFieldId: string) =>
|
||||||
|
mappings.some((m) => m.sourceFieldId === sourceFieldId);
|
||||||
|
|
||||||
|
const activeField = activeId ? getSourceFieldById(activeId) : null;
|
||||||
|
|
||||||
|
const getSourceTypeLabel = () => {
|
||||||
|
switch (sourceType) {
|
||||||
|
case "webhook":
|
||||||
|
return "Webhook Payload";
|
||||||
|
case "email":
|
||||||
|
return "Email Fields";
|
||||||
|
case "csv":
|
||||||
|
return "CSV Columns";
|
||||||
|
default:
|
||||||
|
return "Source Fields";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||||
|
{/* Email inbox address display */}
|
||||||
|
{sourceType === "email" && (
|
||||||
|
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<MailIcon className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-slate-900">Your feedback inbox</p>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500">
|
||||||
|
Forward emails to this address to capture feedback automatically
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<code className="rounded bg-white px-2 py-1 font-mono text-sm text-blue-700">
|
||||||
|
{inboxEmail}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyEmail}
|
||||||
|
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-blue-600 hover:bg-blue-100">
|
||||||
|
<CopyIcon className="h-3 w-3" />
|
||||||
|
{emailCopied ? "Copied!" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
{/* Source Fields Panel */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-slate-700">{getSourceTypeLabel()}</h4>
|
||||||
|
|
||||||
|
{sourceFields.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{sourceType === "webhook"
|
||||||
|
? "Click 'Simulate webhook' to load sample fields"
|
||||||
|
: sourceType === "email"
|
||||||
|
? "Click 'Load email fields' to see available fields"
|
||||||
|
: sourceType === "csv"
|
||||||
|
? "Click 'Load sample CSV' to see columns"
|
||||||
|
: "No source fields loaded yet"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sourceFields.map((field) => (
|
||||||
|
<DraggableSourceField key={field.id} field={field} isMapped={isSourceFieldMapped(field.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email-specific options */}
|
||||||
|
{sourceType === "email" && onDeriveFromAttachmentsChange && (
|
||||||
|
<div className="mt-4 flex items-center justify-between rounded-lg border border-slate-200 bg-white p-3">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-slate-900">Derive context from attachments</span>
|
||||||
|
<Badge text="AI" type="gray" size="tiny" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Extract additional context from email attachments using AI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Switch checked={deriveFromAttachments} onCheckedChange={onDeriveFromAttachmentsChange} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target Fields Panel */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-slate-700">Hub Feedback Record Fields</h4>
|
||||||
|
|
||||||
|
{/* Required Fields */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Required</p>
|
||||||
|
{requiredFields.map((field) => (
|
||||||
|
<DroppableTargetField
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
mappedSourceField={getMappedSourceField(field.id) ?? null}
|
||||||
|
mapping={getMappingForTarget(field.id)}
|
||||||
|
onRemoveMapping={() => handleRemoveMapping(field.id)}
|
||||||
|
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optional Fields */}
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Optional</p>
|
||||||
|
{optionalFields.map((field) => (
|
||||||
|
<DroppableTargetField
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
mappedSourceField={getMappedSourceField(field.id) ?? null}
|
||||||
|
mapping={getMappingForTarget(field.id)}
|
||||||
|
onRemoveMapping={() => handleRemoveMapping(field.id)}
|
||||||
|
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeField ? (
|
||||||
|
<div className="border-brand-dark rounded-md border bg-white p-2 text-sm shadow-lg">
|
||||||
|
<span className="font-medium">{activeField.name}</span>
|
||||||
|
<span className="ml-2 text-xs text-slate-500">({activeField.type})</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
|
import { SOURCE_OPTIONS, TSourceType } from "./types";
|
||||||
|
|
||||||
|
interface SourceTypeSelectorProps {
|
||||||
|
selectedType: TSourceType | null;
|
||||||
|
onSelectType: (type: TSourceType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceTypeSelector({ selectedType, onSelectType }: SourceTypeSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-slate-600">Select the type of feedback source you want to connect:</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{SOURCE_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
disabled={option.disabled}
|
||||||
|
onClick={() => onSelectType(option.id)}
|
||||||
|
className={`flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors ${
|
||||||
|
selectedType === option.id
|
||||||
|
? "border-brand-dark bg-slate-50"
|
||||||
|
: option.disabled
|
||||||
|
? "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60"
|
||||||
|
: "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
|
||||||
|
}`}>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-slate-900">{option.name}</span>
|
||||||
|
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{option.description}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`ml-4 h-5 w-5 rounded-full border-2 ${
|
||||||
|
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
|
||||||
|
}`}>
|
||||||
|
{selectedType === option.id && (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||||
|
import { CreateSourceModal } from "./create-source-modal";
|
||||||
|
import { EditSourceModal } from "./edit-source-modal";
|
||||||
|
import { SourcesTable } from "./sources-table";
|
||||||
|
import { TSourceConnection } from "./types";
|
||||||
|
|
||||||
|
interface SourcesSectionProps {
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourcesSection({ environmentId }: SourcesSectionProps) {
|
||||||
|
const [sources, setSources] = useState<TSourceConnection[]>([]);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [editingSource, setEditingSource] = useState<TSourceConnection | null>(null);
|
||||||
|
|
||||||
|
const handleCreateSource = (source: TSourceConnection) => {
|
||||||
|
setSources((prev) => [...prev, source]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateSource = (updatedSource: TSourceConnection) => {
|
||||||
|
setSources((prev) => prev.map((s) => (s.id === updatedSource.id ? updatedSource : s)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSource = (sourceId: string) => {
|
||||||
|
setSources((prev) => prev.filter((s) => s.id !== sourceId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSourceClick = (source: TSourceConnection) => {
|
||||||
|
setEditingSource(source);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<PageHeader
|
||||||
|
pageTitle="Unify Feedback"
|
||||||
|
cta={
|
||||||
|
<CreateSourceModal
|
||||||
|
open={isCreateModalOpen}
|
||||||
|
onOpenChange={setIsCreateModalOpen}
|
||||||
|
onCreateSource={handleCreateSource}
|
||||||
|
/>
|
||||||
|
}>
|
||||||
|
<UnifyConfigNavigation environmentId={environmentId} />
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SourcesTable sources={sources} onSourceClick={handleSourceClick} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditSourceModal
|
||||||
|
source={editingSource}
|
||||||
|
open={editingSource !== null}
|
||||||
|
onOpenChange={(open) => !open && setEditingSource(null)}
|
||||||
|
onUpdateSource={handleUpdateSource}
|
||||||
|
onDeleteSource={handleDeleteSource}
|
||||||
|
/>
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { FileSpreadsheetIcon, GlobeIcon, MailIcon, MessageSquareIcon, WebhookIcon } from "lucide-react";
|
||||||
|
import { TSourceType } from "./types";
|
||||||
|
|
||||||
|
interface SourcesTableDataRowProps {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: TSourceType;
|
||||||
|
mappingsCount: number;
|
||||||
|
createdAt: Date;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceIcon(type: TSourceType) {
|
||||||
|
switch (type) {
|
||||||
|
case "formbricks":
|
||||||
|
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
|
||||||
|
case "webhook":
|
||||||
|
return <WebhookIcon className="h-4 w-4 text-slate-500" />;
|
||||||
|
case "email":
|
||||||
|
return <MailIcon className="h-4 w-4 text-slate-500" />;
|
||||||
|
case "csv":
|
||||||
|
return <FileSpreadsheetIcon className="h-4 w-4 text-slate-500" />;
|
||||||
|
case "slack":
|
||||||
|
return <MessageSquareIcon className="h-4 w-4 text-slate-500" />;
|
||||||
|
default:
|
||||||
|
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceTypeLabel(type: TSourceType) {
|
||||||
|
switch (type) {
|
||||||
|
case "formbricks":
|
||||||
|
return "Formbricks";
|
||||||
|
case "webhook":
|
||||||
|
return "Webhook";
|
||||||
|
case "email":
|
||||||
|
return "Email";
|
||||||
|
case "csv":
|
||||||
|
return "CSV";
|
||||||
|
case "slack":
|
||||||
|
return "Slack";
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourcesTableDataRow({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
mappingsCount,
|
||||||
|
createdAt,
|
||||||
|
onClick,
|
||||||
|
}: SourcesTableDataRowProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="grid h-12 min-h-12 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<div className="col-span-1 flex items-center pl-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getSourceIcon(type)}
|
||||||
|
<span className="hidden text-xs text-slate-500 sm:inline">{getSourceTypeLabel(type)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-6 flex items-center">
|
||||||
|
<span className="truncate font-medium text-slate-900">{name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-600 sm:flex">
|
||||||
|
{mappingsCount} {mappingsCount === 1 ? "field" : "fields"}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3 hidden items-center justify-end pr-4 text-sm text-slate-500 sm:flex">
|
||||||
|
{formatDistanceToNow(createdAt, { addSuffix: true })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SourcesTableDataRow } from "./sources-table-data-row";
|
||||||
|
import { TSourceConnection } from "./types";
|
||||||
|
|
||||||
|
interface SourcesTableProps {
|
||||||
|
sources: TSourceConnection[];
|
||||||
|
onSourceClick: (source: TSourceConnection) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourcesTable({ sources, onSourceClick }: SourcesTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||||
|
<div className="col-span-1 pl-6">Type</div>
|
||||||
|
<div className="col-span-6">Name</div>
|
||||||
|
<div className="col-span-2 hidden text-center sm:block">Mappings</div>
|
||||||
|
<div className="col-span-3 hidden pr-6 text-right sm:block">Created</div>
|
||||||
|
</div>
|
||||||
|
{sources.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<p className="text-sm text-slate-500">No sources connected yet. Add a source to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{sources.map((source) => (
|
||||||
|
<SourcesTableDataRow
|
||||||
|
key={source.id}
|
||||||
|
id={source.id}
|
||||||
|
name={source.name}
|
||||||
|
type={source.type}
|
||||||
|
mappingsCount={source.mappings.length}
|
||||||
|
createdAt={source.createdAt}
|
||||||
|
onClick={() => onSourceClick(source)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+543
@@ -0,0 +1,543 @@
|
|||||||
|
// Source types for the feedback source connections
|
||||||
|
export type TSourceType = "formbricks" | "webhook" | "email" | "csv" | "slack";
|
||||||
|
|
||||||
|
export interface TSourceOption {
|
||||||
|
id: TSourceType;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
disabled: boolean;
|
||||||
|
badge?: {
|
||||||
|
text: string;
|
||||||
|
type: "success" | "gray" | "warning";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SOURCE_OPTIONS: TSourceOption[] = [
|
||||||
|
{
|
||||||
|
id: "formbricks",
|
||||||
|
name: "Formbricks Surveys",
|
||||||
|
description: "Connect feedback from your Formbricks surveys",
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "webhook",
|
||||||
|
name: "Webhook",
|
||||||
|
description: "Receive feedback via webhook with custom mapping",
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email",
|
||||||
|
name: "Email",
|
||||||
|
description: "Import feedback from email with custom mapping",
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "csv",
|
||||||
|
name: "CSV Import",
|
||||||
|
description: "Import feedback from CSV files",
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slack",
|
||||||
|
name: "Slack Message",
|
||||||
|
description: "Connect feedback from Slack channels",
|
||||||
|
disabled: true,
|
||||||
|
badge: {
|
||||||
|
text: "Coming soon",
|
||||||
|
type: "gray",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Formbricks Survey types for survey selection
|
||||||
|
export interface TFormbricksSurveyQuestion {
|
||||||
|
id: string;
|
||||||
|
type: "openText" | "rating" | "nps" | "csat" | "multipleChoice" | "checkbox" | "date";
|
||||||
|
headline: string;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TFormbricksSurvey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: "draft" | "active" | "paused" | "completed";
|
||||||
|
responseCount: number;
|
||||||
|
questions: TFormbricksSurveyQuestion[];
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock surveys for POC
|
||||||
|
export const MOCK_FORMBRICKS_SURVEYS: TFormbricksSurvey[] = [
|
||||||
|
{
|
||||||
|
id: "survey_nps_q1",
|
||||||
|
name: "Q1 2024 NPS Survey",
|
||||||
|
status: "active",
|
||||||
|
responseCount: 1247,
|
||||||
|
createdAt: new Date("2024-01-15"),
|
||||||
|
questions: [
|
||||||
|
{ id: "q_nps", type: "nps", headline: "How likely are you to recommend us?", required: true },
|
||||||
|
{
|
||||||
|
id: "q_reason",
|
||||||
|
type: "openText",
|
||||||
|
headline: "What's the main reason for your score?",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q_improve",
|
||||||
|
type: "openText",
|
||||||
|
headline: "What could we do to improve?",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "survey_product_feedback",
|
||||||
|
name: "Product Feedback Survey",
|
||||||
|
status: "active",
|
||||||
|
responseCount: 523,
|
||||||
|
createdAt: new Date("2024-02-01"),
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q_satisfaction",
|
||||||
|
type: "rating",
|
||||||
|
headline: "How satisfied are you with the product?",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q_features",
|
||||||
|
type: "multipleChoice",
|
||||||
|
headline: "Which features do you use most?",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q_missing",
|
||||||
|
type: "openText",
|
||||||
|
headline: "What features are you missing?",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{ id: "q_feedback", type: "openText", headline: "Any other feedback?", required: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "survey_onboarding",
|
||||||
|
name: "Onboarding Experience",
|
||||||
|
status: "active",
|
||||||
|
responseCount: 89,
|
||||||
|
createdAt: new Date("2024-03-10"),
|
||||||
|
questions: [
|
||||||
|
{ id: "q_easy", type: "csat", headline: "How easy was the onboarding process?", required: true },
|
||||||
|
{
|
||||||
|
id: "q_time",
|
||||||
|
type: "multipleChoice",
|
||||||
|
headline: "How long did onboarding take?",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q_help",
|
||||||
|
type: "checkbox",
|
||||||
|
headline: "Which resources did you find helpful?",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q_suggestions",
|
||||||
|
type: "openText",
|
||||||
|
headline: "Any suggestions for improvement?",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "survey_support",
|
||||||
|
name: "Support Satisfaction",
|
||||||
|
status: "paused",
|
||||||
|
responseCount: 312,
|
||||||
|
createdAt: new Date("2024-01-20"),
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q_support_rating",
|
||||||
|
type: "rating",
|
||||||
|
headline: "How would you rate your support experience?",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q_resolved",
|
||||||
|
type: "multipleChoice",
|
||||||
|
headline: "Was your issue resolved?",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{ id: "q_comments", type: "openText", headline: "Additional comments", required: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper to get question type label
|
||||||
|
export function getQuestionTypeLabel(type: TFormbricksSurveyQuestion["type"]): string {
|
||||||
|
switch (type) {
|
||||||
|
case "openText":
|
||||||
|
return "Open Text";
|
||||||
|
case "rating":
|
||||||
|
return "Rating";
|
||||||
|
case "nps":
|
||||||
|
return "NPS";
|
||||||
|
case "csat":
|
||||||
|
return "CSAT";
|
||||||
|
case "multipleChoice":
|
||||||
|
return "Multiple Choice";
|
||||||
|
case "checkbox":
|
||||||
|
return "Checkbox";
|
||||||
|
case "date":
|
||||||
|
return "Date";
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to map question type to FeedbackRecord field_type
|
||||||
|
export function questionTypeToFieldType(type: TFormbricksSurveyQuestion["type"]): TFeedbackRecordFieldType {
|
||||||
|
switch (type) {
|
||||||
|
case "openText":
|
||||||
|
return "text";
|
||||||
|
case "rating":
|
||||||
|
return "rating";
|
||||||
|
case "nps":
|
||||||
|
return "nps";
|
||||||
|
case "csat":
|
||||||
|
return "csat";
|
||||||
|
case "multipleChoice":
|
||||||
|
case "checkbox":
|
||||||
|
return "categorical";
|
||||||
|
case "date":
|
||||||
|
return "date";
|
||||||
|
default:
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field mapping types - supports both source field mapping and static values
|
||||||
|
export interface TFieldMapping {
|
||||||
|
targetFieldId: string;
|
||||||
|
// Either map from a source field OR set a static value
|
||||||
|
sourceFieldId?: string;
|
||||||
|
staticValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TSourceConnection {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: TSourceType;
|
||||||
|
mappings: TFieldMapping[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedbackRecord field types (enum values for field_type)
|
||||||
|
export type TFeedbackRecordFieldType =
|
||||||
|
| "text"
|
||||||
|
| "categorical"
|
||||||
|
| "nps"
|
||||||
|
| "csat"
|
||||||
|
| "ces"
|
||||||
|
| "rating"
|
||||||
|
| "number"
|
||||||
|
| "boolean"
|
||||||
|
| "date";
|
||||||
|
|
||||||
|
// Field types for the Hub schema
|
||||||
|
export type TTargetFieldType = "string" | "enum" | "timestamp" | "float64" | "boolean" | "jsonb" | "string[]";
|
||||||
|
|
||||||
|
export interface TTargetField {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: TTargetFieldType;
|
||||||
|
required: boolean;
|
||||||
|
description: string;
|
||||||
|
// For enum fields, the possible values
|
||||||
|
enumValues?: string[];
|
||||||
|
// For string fields, example static values that could be set
|
||||||
|
exampleStaticValues?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TSourceField {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
sampleValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum values for field_type
|
||||||
|
export const FIELD_TYPE_ENUM_VALUES: TFeedbackRecordFieldType[] = [
|
||||||
|
"text",
|
||||||
|
"categorical",
|
||||||
|
"nps",
|
||||||
|
"csat",
|
||||||
|
"ces",
|
||||||
|
"rating",
|
||||||
|
"number",
|
||||||
|
"boolean",
|
||||||
|
"date",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Target fields based on the FeedbackRecord schema
|
||||||
|
export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
|
||||||
|
// Required fields
|
||||||
|
{
|
||||||
|
id: "collected_at",
|
||||||
|
name: "Collected At",
|
||||||
|
type: "timestamp",
|
||||||
|
required: true,
|
||||||
|
description: "When the feedback was originally collected",
|
||||||
|
exampleStaticValues: ["$now"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "source_type",
|
||||||
|
name: "Source Type",
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
description: "Type of source (e.g., survey, review, support)",
|
||||||
|
exampleStaticValues: ["survey", "review", "support", "email", "qualtrics", "typeform", "intercom"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "field_id",
|
||||||
|
name: "Field ID",
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
description: "Unique question/field identifier",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "field_type",
|
||||||
|
name: "Field Type",
|
||||||
|
type: "enum",
|
||||||
|
required: true,
|
||||||
|
description: "Data type (text, nps, csat, rating, etc.)",
|
||||||
|
enumValues: FIELD_TYPE_ENUM_VALUES,
|
||||||
|
},
|
||||||
|
// Optional fields
|
||||||
|
{
|
||||||
|
id: "tenant_id",
|
||||||
|
name: "Tenant ID",
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "Tenant/organization identifier for multi-tenant deployments",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "response_id",
|
||||||
|
name: "Response ID",
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "Groups multiple answers from a single submission",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "source_id",
|
||||||
|
name: "Source ID",
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "Reference to survey/form/ticket/review ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "source_name",
|
||||||
|
name: "Source Name",
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "Human-readable source name for display",
|
||||||
|
exampleStaticValues: ["Product Feedback", "Customer Support", "NPS Survey", "Qualtrics Import"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "field_label",
|
||||||
|
name: "Field Label",
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "Question text or field label for display",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "value_text",
|
||||||
|
name: "Value (Text)",
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "Text responses (feedback, comments, open-ended answers)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "value_number",
|
||||||
|
name: "Value (Number)",
|
||||||
|
type: "float64",
|
||||||
|
required: false,
|
||||||
|
description: "Numeric responses (ratings, scores, NPS, CSAT)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "value_boolean",
|
||||||
|
name: "Value (Boolean)",
|
||||||
|
type: "boolean",
|
||||||
|
required: false,
|
||||||
|
description: "Yes/no responses",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "value_date",
|
||||||
|
name: "Value (Date)",
|
||||||
|
type: "timestamp",
|
||||||
|
required: false,
|
||||||
|
description: "Date/datetime responses",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "metadata",
|
||||||
|
name: "Metadata",
|
||||||
|
type: "jsonb",
|
||||||
|
required: false,
|
||||||
|
description: "Flexible context (device, location, campaign, custom fields)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "language",
|
||||||
|
name: "Language",
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "ISO 639-1 language code (e.g., en, de, fr)",
|
||||||
|
exampleStaticValues: ["en", "de", "fr", "es", "pt", "ja", "zh"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user_identifier",
|
||||||
|
name: "User Identifier",
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "Anonymous user ID for tracking (hashed, never PII)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sample webhook payload for testing
|
||||||
|
export const SAMPLE_WEBHOOK_PAYLOAD = {
|
||||||
|
id: "resp_12345",
|
||||||
|
timestamp: "2024-01-15T10:30:00Z",
|
||||||
|
survey_id: "survey_abc",
|
||||||
|
survey_name: "Product Feedback Survey",
|
||||||
|
question_id: "q1",
|
||||||
|
question_text: "How satisfied are you with our product?",
|
||||||
|
answer_type: "rating",
|
||||||
|
answer_value: 4,
|
||||||
|
user_id: "user_xyz",
|
||||||
|
metadata: {
|
||||||
|
device: "mobile",
|
||||||
|
browser: "Safari",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Email source fields (simplified)
|
||||||
|
export const EMAIL_SOURCE_FIELDS: TSourceField[] = [
|
||||||
|
{ id: "subject", name: "Subject", type: "string", sampleValue: "Feature Request: Dark Mode" },
|
||||||
|
{
|
||||||
|
id: "body",
|
||||||
|
name: "Body (Text)",
|
||||||
|
type: "string",
|
||||||
|
sampleValue: "I would love to see a dark mode option...",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// CSV sample columns
|
||||||
|
export const SAMPLE_CSV_COLUMNS = "timestamp,customer_id,rating,feedback_text,category";
|
||||||
|
|
||||||
|
// Helper function to parse payload to source fields
|
||||||
|
export function parsePayloadToFields(payload: Record<string, unknown>): TSourceField[] {
|
||||||
|
const fields: TSourceField[] = [];
|
||||||
|
|
||||||
|
function extractFields(obj: Record<string, unknown>, prefix = ""): void {
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
const fieldId = prefix ? `${prefix}.${key}` : key;
|
||||||
|
const fieldName = prefix ? `${prefix}.${key}` : key;
|
||||||
|
|
||||||
|
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
extractFields(value as Record<string, unknown>, fieldId);
|
||||||
|
} else {
|
||||||
|
let type = "string";
|
||||||
|
if (typeof value === "number") type = "number";
|
||||||
|
if (typeof value === "boolean") type = "boolean";
|
||||||
|
if (Array.isArray(value)) type = "array";
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
id: fieldId,
|
||||||
|
name: fieldName,
|
||||||
|
type,
|
||||||
|
sampleValue: String(value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extractFields(payload);
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to parse CSV columns to source fields
|
||||||
|
export function parseCSVColumnsToFields(columns: string): TSourceField[] {
|
||||||
|
return columns.split(",").map((col) => {
|
||||||
|
const trimmedCol = col.trim();
|
||||||
|
return {
|
||||||
|
id: trimmedCol,
|
||||||
|
name: trimmedCol,
|
||||||
|
type: "string",
|
||||||
|
sampleValue: `Sample ${trimmedCol}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI suggested mappings for different source types
|
||||||
|
// Maps source field IDs to target field IDs
|
||||||
|
export interface TAISuggestedMapping {
|
||||||
|
// Maps source field ID -> target field ID
|
||||||
|
fieldMappings: Record<string, string>;
|
||||||
|
// Static values to set on target fields
|
||||||
|
staticValues: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AI_SUGGESTED_MAPPINGS: Record<TSourceType, TAISuggestedMapping> = {
|
||||||
|
webhook: {
|
||||||
|
fieldMappings: {
|
||||||
|
timestamp: "collected_at",
|
||||||
|
survey_id: "source_id",
|
||||||
|
survey_name: "source_name",
|
||||||
|
question_id: "field_id",
|
||||||
|
question_text: "field_label",
|
||||||
|
answer_value: "value_number",
|
||||||
|
user_id: "user_identifier",
|
||||||
|
},
|
||||||
|
staticValues: {
|
||||||
|
source_type: "survey",
|
||||||
|
field_type: "rating",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
fieldMappings: {
|
||||||
|
subject: "field_label",
|
||||||
|
body: "value_text",
|
||||||
|
},
|
||||||
|
staticValues: {
|
||||||
|
collected_at: "$now",
|
||||||
|
source_type: "email",
|
||||||
|
field_type: "text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
csv: {
|
||||||
|
fieldMappings: {
|
||||||
|
timestamp: "collected_at",
|
||||||
|
customer_id: "user_identifier",
|
||||||
|
rating: "value_number",
|
||||||
|
feedback_text: "value_text",
|
||||||
|
category: "field_label",
|
||||||
|
},
|
||||||
|
staticValues: {
|
||||||
|
source_type: "survey",
|
||||||
|
field_type: "rating",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
formbricks: {
|
||||||
|
fieldMappings: {},
|
||||||
|
staticValues: {
|
||||||
|
source_type: "survey",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slack: {
|
||||||
|
fieldMappings: {},
|
||||||
|
staticValues: {
|
||||||
|
source_type: "support",
|
||||||
|
field_type: "text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal step types
|
||||||
|
export type TCreateSourceStep = "selectType" | "mapping";
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
import { SourcesSection } from "./components/sources-page-client";
|
||||||
|
|
||||||
|
export default async function UnifySourcesPage(props: { params: Promise<{ environmentId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
return <SourcesSection environmentId={params.environmentId} />;
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
|
||||||
|
export type AddKeywordModalLevel = "L1" | "L2" | "L3";
|
||||||
|
|
||||||
|
interface AddKeywordModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
level: AddKeywordModalLevel;
|
||||||
|
parentName?: string;
|
||||||
|
onConfirm: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddKeywordModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
level,
|
||||||
|
parentName,
|
||||||
|
onConfirm,
|
||||||
|
}: AddKeywordModalProps) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
const handleClose = (nextOpen: boolean) => {
|
||||||
|
if (!nextOpen) setName("");
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
toast.error("Please enter a keyword name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onConfirm(trimmed);
|
||||||
|
setName("");
|
||||||
|
onOpenChange(false);
|
||||||
|
toast.success("Keyword added (demo).");
|
||||||
|
};
|
||||||
|
|
||||||
|
const title =
|
||||||
|
level === "L1" ? "Add L1 keyword" : level === "L2" ? "Add L2 keyword" : "Add L3 keyword";
|
||||||
|
const description =
|
||||||
|
level === "L1"
|
||||||
|
? "Add a new top-level keyword."
|
||||||
|
: parentName
|
||||||
|
? `Add a new ${level} keyword under "${parentName}".`
|
||||||
|
: `Add a new ${level} keyword.`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogBody>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="keyword-name">Keyword name</Label>
|
||||||
|
<Input
|
||||||
|
id="keyword-name"
|
||||||
|
placeholder="e.g. New category"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter className="m-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Add keyword</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronRightIcon,
|
||||||
|
LightbulbIcon,
|
||||||
|
MessageCircleIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
WrenchIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { H4, Small } from "@/modules/ui/components/typography";
|
||||||
|
import type { TaxonomyDetail, TaxonomyThemeItem } from "../types";
|
||||||
|
|
||||||
|
const THEME_COLORS: Record<string, string> = {
|
||||||
|
red: "bg-red-400",
|
||||||
|
orange: "bg-orange-400",
|
||||||
|
yellow: "bg-amber-400",
|
||||||
|
green: "bg-emerald-500",
|
||||||
|
slate: "bg-slate-400",
|
||||||
|
};;
|
||||||
|
|
||||||
|
function getThemeIcon(icon?: TaxonomyThemeItem["icon"]) {
|
||||||
|
switch (icon) {
|
||||||
|
case "warning":
|
||||||
|
return <TriangleAlertIcon className="size-4 text-amber-500" />;
|
||||||
|
case "wrench":
|
||||||
|
return <WrenchIcon className="size-4 text-slate-500" />;
|
||||||
|
case "message-circle":
|
||||||
|
return <MessageCircleIcon className="size-4 text-slate-500" />;
|
||||||
|
case "lightbulb":
|
||||||
|
return <LightbulbIcon className="size-4 text-amber-500" />;
|
||||||
|
default:
|
||||||
|
return <MessageCircleIcon className="size-4 text-slate-400" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeItemRowProps {
|
||||||
|
item: TaxonomyThemeItem;
|
||||||
|
depth?: number;
|
||||||
|
themeSearch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeItemRow({ item, depth = 0, themeSearch }: ThemeItemRowProps) {
|
||||||
|
const [expanded, setExpanded] = useState(depth === 0 && (item.children?.length ?? 0) > 0);
|
||||||
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
|
const labelLower = item.label.toLowerCase();
|
||||||
|
const matchesSearch =
|
||||||
|
!themeSearch.trim() || labelLower.includes(themeSearch.trim().toLowerCase());
|
||||||
|
const childMatches =
|
||||||
|
hasChildren &&
|
||||||
|
item.children!.some((c) =>
|
||||||
|
c.label.toLowerCase().includes(themeSearch.trim().toLowerCase())
|
||||||
|
);
|
||||||
|
const show = matchesSearch || childMatches;
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 py-1.5 text-sm",
|
||||||
|
depth === 0 ? "font-medium text-slate-800" : "text-slate-600"
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: depth * 16 + 4 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => hasChildren && setExpanded(!expanded)}
|
||||||
|
className="flex shrink-0 items-center justify-center text-slate-400 hover:text-slate-600">
|
||||||
|
{hasChildren ? (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4 transition-transform", expanded && "rotate-90")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{getThemeIcon(item.icon)}
|
||||||
|
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||||
|
<Small color="muted" className="shrink-0">
|
||||||
|
{item.count}
|
||||||
|
</Small>
|
||||||
|
</div>
|
||||||
|
{hasChildren && expanded && (
|
||||||
|
<div className="border-l border-slate-200 pl-2">
|
||||||
|
{item.children!.map((child) => (
|
||||||
|
<ThemeItemRow
|
||||||
|
key={child.id}
|
||||||
|
item={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
themeSearch={themeSearch}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaxonomyDetailPanelProps {
|
||||||
|
detail: TaxonomyDetail | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaxonomyDetailPanel({ detail }: TaxonomyDetailPanelProps) {
|
||||||
|
const [themeSearch, setThemeSearch] = useState("");
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
|
||||||
|
<Small color="muted">Select a Level 3 keyword to view details.</Small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalThemes = detail.themes.reduce((s, t) => s + t.count, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="flex flex-col gap-5 overflow-y-auto p-4">
|
||||||
|
<div className="border-b border-slate-200 pb-4">
|
||||||
|
<H4 className="mb-1">{detail.keywordName}</H4>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Small color="muted">{detail.count} responses</Small>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm font-medium text-slate-600 underline-offset-2 hover:underline">
|
||||||
|
View all →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<H4 className="mb-1 text-sm">Description</H4>
|
||||||
|
<Small color="muted" className="leading-relaxed">
|
||||||
|
{detail.description}
|
||||||
|
</Small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<H4 className="text-sm">{detail.themes.length} themes</H4>
|
||||||
|
<div className="flex h-2 flex-1 max-w-[120px] overflow-hidden rounded-full bg-slate-100">
|
||||||
|
{detail.themes.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={cn(THEME_COLORS[t.color] ?? "bg-slate-400")}
|
||||||
|
style={{
|
||||||
|
width: totalThemes ? `${(t.count / totalThemes) * 100}%` : "0%",
|
||||||
|
}}
|
||||||
|
title={t.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="Search themes"
|
||||||
|
value={themeSearch}
|
||||||
|
onChange={(e) => setThemeSearch(e.target.value)}
|
||||||
|
className="mb-3 h-9 text-sm"
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{detail.themeItems.map((item) => (
|
||||||
|
<ThemeItemRow key={item.id} item={item} themeSearch={themeSearch} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { formatCount } from "../lib/mock-data";
|
||||||
|
import type { TaxonomyKeyword } from "../types";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
|
||||||
|
interface TaxonomyKeywordColumnProps {
|
||||||
|
title: string;
|
||||||
|
keywords: TaxonomyKeyword[];
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
addButtonLabel?: string;
|
||||||
|
onAdd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaxonomyKeywordColumn({
|
||||||
|
title,
|
||||||
|
keywords,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
addButtonLabel,
|
||||||
|
onAdd,
|
||||||
|
}: TaxonomyKeywordColumnProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="border-b border-slate-200 px-4 py-3">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-[320px] flex-1 flex-col overflow-y-auto">
|
||||||
|
{keywords.map((kw) => (
|
||||||
|
<button
|
||||||
|
key={kw.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(kw.id)}
|
||||||
|
className={cn(
|
||||||
|
"grid w-full grid-cols-[1fr,auto] content-center gap-3 border-b border-slate-100 px-4 py-3 text-left transition-colors last:border-b-0",
|
||||||
|
selectedId === kw.id ? "bg-slate-50" : "hover:bg-slate-50"
|
||||||
|
)}>
|
||||||
|
<span className="min-w-0 truncate text-sm font-medium text-slate-800">{kw.name}</span>
|
||||||
|
<span className="text-sm text-slate-500">{formatCount(kw.count)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{addButtonLabel && (
|
||||||
|
<div className="border-t border-slate-200 p-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" className="w-full" onClick={onAdd}>
|
||||||
|
+ {addButtonLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||||
|
import { getDetailForL3, getL2Keywords, getL3Keywords, MOCK_LEVEL1_KEYWORDS } from "../lib/mock-data";
|
||||||
|
import type { TaxonomyKeyword } from "../types";
|
||||||
|
import { AddKeywordModal } from "./AddKeywordModal";
|
||||||
|
import { TaxonomyDetailPanel } from "./TaxonomyDetailPanel";
|
||||||
|
import { TaxonomyKeywordColumn } from "./TaxonomyKeywordColumn";
|
||||||
|
|
||||||
|
interface TaxonomySectionProps {
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaxonomySection({ environmentId }: TaxonomySectionProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selectedL1Id, setSelectedL1Id] = useState<string | null>("l1-1");
|
||||||
|
const [selectedL2Id, setSelectedL2Id] = useState<string | null>("l2-1a");
|
||||||
|
const [selectedL3Id, setSelectedL3Id] = useState<string | null>("l3-1a");
|
||||||
|
const [addKeywordModalOpen, setAddKeywordModalOpen] = useState(false);
|
||||||
|
const [addKeywordModalLevel, setAddKeywordModalLevel] = useState<"L1" | "L2" | "L3">("L1");
|
||||||
|
const [customL1Keywords, setCustomL1Keywords] = useState<TaxonomyKeyword[]>([]);
|
||||||
|
const [customL2Keywords, setCustomL2Keywords] = useState<TaxonomyKeyword[]>([]);
|
||||||
|
const [customL3Keywords, setCustomL3Keywords] = useState<TaxonomyKeyword[]>([]);
|
||||||
|
|
||||||
|
const l2Keywords = useMemo(() => {
|
||||||
|
const fromMock = selectedL1Id ? getL2Keywords(selectedL1Id) : [];
|
||||||
|
const custom = customL2Keywords.filter((k) => k.parentId === selectedL1Id);
|
||||||
|
return [...fromMock, ...custom];
|
||||||
|
}, [selectedL1Id, customL2Keywords]);
|
||||||
|
|
||||||
|
const l3Keywords = useMemo(() => {
|
||||||
|
const fromMock = selectedL2Id ? getL3Keywords(selectedL2Id) : [];
|
||||||
|
const custom = customL3Keywords.filter((k) => k.parentId === selectedL2Id);
|
||||||
|
return [...fromMock, ...custom];
|
||||||
|
}, [selectedL2Id, customL3Keywords]);
|
||||||
|
const detail = useMemo(
|
||||||
|
() => (selectedL3Id ? getDetailForL3(selectedL3Id) : null),
|
||||||
|
[selectedL3Id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const l1Keywords = useMemo(
|
||||||
|
() => [...MOCK_LEVEL1_KEYWORDS, ...customL1Keywords],
|
||||||
|
[customL1Keywords]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterKeywords = (list: TaxonomyKeyword[], q: string) => {
|
||||||
|
if (!q.trim()) return list;
|
||||||
|
const lower = q.trim().toLowerCase();
|
||||||
|
return list.filter((k) => k.name.toLowerCase().includes(lower));
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredL1 = useMemo(
|
||||||
|
() => filterKeywords(l1Keywords, searchQuery),
|
||||||
|
[l1Keywords, searchQuery]
|
||||||
|
);
|
||||||
|
const filteredL2 = useMemo(() => filterKeywords(l2Keywords, searchQuery), [l2Keywords, searchQuery]);
|
||||||
|
const filteredL3 = useMemo(() => filterKeywords(l3Keywords, searchQuery), [l3Keywords, searchQuery]);
|
||||||
|
|
||||||
|
const selectedL1Name = useMemo(
|
||||||
|
() => l1Keywords.find((k) => k.id === selectedL1Id)?.name,
|
||||||
|
[l1Keywords, selectedL1Id]
|
||||||
|
);
|
||||||
|
const selectedL2Name = useMemo(
|
||||||
|
() => l2Keywords.find((k) => k.id === selectedL2Id)?.name,
|
||||||
|
[l2Keywords, selectedL2Id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addKeywordParentName =
|
||||||
|
addKeywordModalLevel === "L2" ? selectedL1Name : addKeywordModalLevel === "L3" ? selectedL2Name : undefined;
|
||||||
|
|
||||||
|
const handleAddKeyword = (name: string) => {
|
||||||
|
if (addKeywordModalLevel === "L1") {
|
||||||
|
const id = `custom-l1-${crypto.randomUUID()}`;
|
||||||
|
setCustomL1Keywords((prev) => [...prev, { id, name, count: 0 }]);
|
||||||
|
setSelectedL1Id(id);
|
||||||
|
setSelectedL2Id(null);
|
||||||
|
setSelectedL3Id(null);
|
||||||
|
} else if (addKeywordModalLevel === "L2" && selectedL1Id) {
|
||||||
|
const id = `custom-l2-${crypto.randomUUID()}`;
|
||||||
|
setCustomL2Keywords((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id, name, count: 0, parentId: selectedL1Id },
|
||||||
|
]);
|
||||||
|
setSelectedL2Id(id);
|
||||||
|
setSelectedL3Id(null);
|
||||||
|
} else if (addKeywordModalLevel === "L3" && selectedL2Id) {
|
||||||
|
const id = `custom-l3-${crypto.randomUUID()}`;
|
||||||
|
setCustomL3Keywords((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id, name, count: 0, parentId: selectedL2Id },
|
||||||
|
]);
|
||||||
|
setSelectedL3Id(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<PageHeader pageTitle="Unify Feedback">
|
||||||
|
<UnifyConfigNavigation environmentId={environmentId} />
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Find in taxonomy..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<TaxonomyKeywordColumn
|
||||||
|
title={`Level 1 Keywords (${filteredL1.length})`}
|
||||||
|
keywords={filteredL1}
|
||||||
|
selectedId={selectedL1Id}
|
||||||
|
onSelect={(id) => {
|
||||||
|
setSelectedL1Id(id);
|
||||||
|
setSelectedL2Id(null);
|
||||||
|
setSelectedL3Id(null);
|
||||||
|
}}
|
||||||
|
addButtonLabel="Add L1 Keyword"
|
||||||
|
onAdd={() => {
|
||||||
|
setAddKeywordModalLevel("L1");
|
||||||
|
setAddKeywordModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<TaxonomyKeywordColumn
|
||||||
|
title={`Level 2 Keywords (${filteredL2.length})`}
|
||||||
|
keywords={filteredL2}
|
||||||
|
selectedId={selectedL2Id}
|
||||||
|
onSelect={(id) => {
|
||||||
|
setSelectedL2Id(id);
|
||||||
|
setSelectedL3Id(null);
|
||||||
|
}}
|
||||||
|
addButtonLabel="Add L2 Keyword"
|
||||||
|
onAdd={() => {
|
||||||
|
setAddKeywordModalLevel("L2");
|
||||||
|
setAddKeywordModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<TaxonomyKeywordColumn
|
||||||
|
title={`Level 3 Keywords (${filteredL3.length})`}
|
||||||
|
keywords={filteredL3}
|
||||||
|
selectedId={selectedL3Id}
|
||||||
|
onSelect={setSelectedL3Id}
|
||||||
|
addButtonLabel="Add L3 Keyword"
|
||||||
|
onAdd={() => {
|
||||||
|
setAddKeywordModalLevel("L3");
|
||||||
|
setAddKeywordModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-[400px] lg:col-span-3">
|
||||||
|
<TaxonomyDetailPanel detail={detail} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddKeywordModal
|
||||||
|
open={addKeywordModalOpen}
|
||||||
|
onOpenChange={setAddKeywordModalOpen}
|
||||||
|
level={addKeywordModalLevel}
|
||||||
|
parentName={addKeywordParentName}
|
||||||
|
onConfirm={handleAddKeyword}
|
||||||
|
/>
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
import type { TaxonomyDetail, TaxonomyKeyword, TaxonomyThemeItem } from "../types";
|
||||||
|
|
||||||
|
export const MOCK_LEVEL1_KEYWORDS: TaxonomyKeyword[] = [
|
||||||
|
{ id: "l1-1", name: "Dashboard", count: 12400 },
|
||||||
|
{ id: "l1-2", name: "Usability", count: 8200 },
|
||||||
|
{ id: "l1-3", name: "Performance", count: 5600 },
|
||||||
|
{ id: "l1-4", name: "Miscellaneous", count: 3100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MOCK_LEVEL2_KEYWORDS: Record<string, TaxonomyKeyword[]> = {
|
||||||
|
"l1-1": [
|
||||||
|
{ id: "l2-1a", name: "Survey overview", count: 5200, parentId: "l1-1" },
|
||||||
|
{ id: "l2-2a", name: "Response metrics", count: 3800, parentId: "l1-1" },
|
||||||
|
{ id: "l2-3a", name: "Analytics & reports", count: 2400, parentId: "l1-1" },
|
||||||
|
{ id: "l2-4a", name: "Widgets & embedding", count: 800, parentId: "l1-1" },
|
||||||
|
{ id: "l2-5a", name: "Not specified", count: 200, parentId: "l1-1" },
|
||||||
|
],
|
||||||
|
"l1-2": [
|
||||||
|
{ id: "l2-1b", name: "Survey builder", count: 3200, parentId: "l1-2" },
|
||||||
|
{ id: "l2-2b", name: "Question types", count: 2100, parentId: "l1-2" },
|
||||||
|
{ id: "l2-3b", name: "Logic & branching", count: 1400, parentId: "l1-2" },
|
||||||
|
{ id: "l2-4b", name: "Styling & theming", count: 900, parentId: "l1-2" },
|
||||||
|
{ id: "l2-5b", name: "Not specified", count: 600, parentId: "l1-2" },
|
||||||
|
],
|
||||||
|
"l1-3": [
|
||||||
|
{ id: "l2-1c", name: "Load time & speed", count: 2200, parentId: "l1-3" },
|
||||||
|
{ id: "l2-2c", name: "Survey rendering", count: 1600, parentId: "l1-3" },
|
||||||
|
{ id: "l2-3c", name: "SDK & integration", count: 1100, parentId: "l1-3" },
|
||||||
|
{ id: "l2-4c", name: "API & data sync", count: 500, parentId: "l1-3" },
|
||||||
|
{ id: "l2-5c", name: "Not specified", count: 200, parentId: "l1-3" },
|
||||||
|
],
|
||||||
|
"l1-4": [
|
||||||
|
{ id: "l2-1d", name: "Feature requests", count: 1500, parentId: "l1-4" },
|
||||||
|
{ id: "l2-2d", name: "Bug reports", count: 900, parentId: "l1-4" },
|
||||||
|
{ id: "l2-3d", name: "Documentation", count: 400, parentId: "l1-4" },
|
||||||
|
{ id: "l2-4d", name: "Not specified", count: 300, parentId: "l1-4" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_LEVEL3_KEYWORDS: Record<string, TaxonomyKeyword[]> = {
|
||||||
|
"l2-1a": [
|
||||||
|
{ id: "l3-1a", name: "In-app surveys", count: 2800, parentId: "l2-1a" },
|
||||||
|
{ id: "l3-2a", name: "Link surveys", count: 1600, parentId: "l2-1a" },
|
||||||
|
{ id: "l3-3a", name: "Response summary", count: 600, parentId: "l2-1a" },
|
||||||
|
{ id: "l3-4a", name: "Not specified", count: 200, parentId: "l2-1a" },
|
||||||
|
],
|
||||||
|
"l2-2a": [
|
||||||
|
{ id: "l3-5a", name: "Completion rate", count: 1800, parentId: "l2-2a" },
|
||||||
|
{ id: "l3-6a", name: "Drop-off points", count: 1200, parentId: "l2-2a" },
|
||||||
|
{ id: "l3-7a", name: "Response distribution", count: 800, parentId: "l2-2a" },
|
||||||
|
],
|
||||||
|
"l2-1b": [
|
||||||
|
{ id: "l3-1b", name: "Drag & drop editor", count: 1400, parentId: "l2-1b" },
|
||||||
|
{ id: "l3-2b", name: "Question configuration", count: 900, parentId: "l2-1b" },
|
||||||
|
{ id: "l3-3b", name: "Multi-language surveys", count: 500, parentId: "l2-1b" },
|
||||||
|
{ id: "l3-4b", name: "Not specified", count: 400, parentId: "l2-1b" },
|
||||||
|
],
|
||||||
|
"l2-2b": [
|
||||||
|
{ id: "l3-5b", name: "Open text & NPS", count: 1100, parentId: "l2-2b" },
|
||||||
|
{ id: "l3-6b", name: "Multiple choice & rating", count: 600, parentId: "l2-2b" },
|
||||||
|
{ id: "l3-7b", name: "File upload & date picker", count: 400, parentId: "l2-2b" },
|
||||||
|
],
|
||||||
|
"l2-1c": [
|
||||||
|
{ id: "l3-1c", name: "Widget initialization", count: 900, parentId: "l2-1c" },
|
||||||
|
{ id: "l3-2c", name: "Survey load delay", count: 700, parentId: "l2-1c" },
|
||||||
|
{ id: "l3-3c", name: "Bundle size impact", count: 600, parentId: "l2-1c" },
|
||||||
|
],
|
||||||
|
"l2-1d": [
|
||||||
|
{ id: "l3-1d", name: "New question types", count: 600, parentId: "l2-1d" },
|
||||||
|
{ id: "l3-2d", name: "Integrations & webhooks", count: 500, parentId: "l2-1d" },
|
||||||
|
{ id: "l3-3d", name: "Export & reporting", count: 400, parentId: "l2-1d" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getL2Keywords(parentL1Id: string): TaxonomyKeyword[] {
|
||||||
|
return MOCK_LEVEL2_KEYWORDS[parentL1Id] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getL3Keywords(parentL2Id: string): TaxonomyKeyword[] {
|
||||||
|
return MOCK_LEVEL3_KEYWORDS[parentL2Id] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_DETAIL_L3: Record<string, TaxonomyDetail> = {
|
||||||
|
"l3-1a": {
|
||||||
|
keywordId: "l3-1a",
|
||||||
|
keywordName: "In-app surveys",
|
||||||
|
count: 2800,
|
||||||
|
description:
|
||||||
|
"Feedback collected directly inside your product. Formbricks in-app surveys are triggered by actions (e.g. page view, click) and can be shown as modal, full-width, or inline widgets.",
|
||||||
|
themes: [
|
||||||
|
{ id: "t1", label: "Issues", count: 1200, color: "red" },
|
||||||
|
{ id: "t2", label: "Ideas", count: 900, color: "orange" },
|
||||||
|
{ id: "t3", label: "Questions", count: 500, color: "yellow" },
|
||||||
|
{ id: "t4", label: "Other", count: 200, color: "green" },
|
||||||
|
],
|
||||||
|
themeItems: [
|
||||||
|
{
|
||||||
|
id: "ti-1",
|
||||||
|
label: "Survey not showing on trigger",
|
||||||
|
count: 420,
|
||||||
|
icon: "warning",
|
||||||
|
children: [
|
||||||
|
{ id: "ti-1-1", label: "Wrong environment or survey ID", count: 200 },
|
||||||
|
{ id: "ti-1-2", label: "Trigger conditions not met", count: 150 },
|
||||||
|
{ id: "ti-1-3", label: "SDK not loaded in time", count: 70 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: "ti-2", label: "Positioning and placement", count: 310, icon: "wrench" },
|
||||||
|
{ id: "ti-3", label: "Request for more trigger types", count: 280, icon: "lightbulb" },
|
||||||
|
{ id: "ti-4", label: "Miscellaneous in-app feedback", count: 190, icon: "message-circle" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"l3-1b": {
|
||||||
|
keywordId: "l3-1b",
|
||||||
|
keywordName: "Drag & drop editor",
|
||||||
|
count: 1400,
|
||||||
|
description:
|
||||||
|
"The Formbricks survey builder lets you add and reorder questions with drag and drop, configure question settings, and preview surveys before publishing.",
|
||||||
|
themes: [
|
||||||
|
{ id: "t1", label: "Issues", count: 600, color: "red" },
|
||||||
|
{ id: "t2", label: "Ideas", count: 500, color: "orange" },
|
||||||
|
{ id: "t3", label: "Questions", count: 250, color: "yellow" },
|
||||||
|
{ id: "t4", label: "Other", count: 50, color: "green" },
|
||||||
|
],
|
||||||
|
themeItems: [
|
||||||
|
{ id: "ti-1", label: "Reordering fails with many questions", count: 220, icon: "warning" },
|
||||||
|
{ id: "ti-2", label: "Request for keyboard shortcuts", count: 180, icon: "lightbulb" },
|
||||||
|
{ id: "ti-3", label: "Undo / redo in editor", count: 150, icon: "lightbulb" },
|
||||||
|
{ id: "ti-4", label: "Miscellaneous builder feedback", count: 100, icon: "message-circle" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"l3-1c": {
|
||||||
|
keywordId: "l3-1c",
|
||||||
|
keywordName: "Widget initialization",
|
||||||
|
count: 900,
|
||||||
|
description:
|
||||||
|
"How quickly the Formbricks widget loads and becomes ready to display surveys. Includes script load time, SDK init, and first-paint for survey UI.",
|
||||||
|
themes: [
|
||||||
|
{ id: "t1", label: "Issues", count: 550, color: "red" },
|
||||||
|
{ id: "t2", label: "Ideas", count: 250, color: "orange" },
|
||||||
|
{ id: "t3", label: "Questions", count: 100, color: "yellow" },
|
||||||
|
{ id: "t4", label: "Other", count: 0, color: "green" },
|
||||||
|
],
|
||||||
|
themeItems: [
|
||||||
|
{ id: "ti-1", label: "Slow init on mobile networks", count: 280, icon: "warning" },
|
||||||
|
{ id: "ti-2", label: "Blocking main thread", count: 180, icon: "warning" },
|
||||||
|
{ id: "ti-3", label: "Lazy-load SDK suggestion", count: 120, icon: "lightbulb" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"l3-1d": {
|
||||||
|
keywordId: "l3-1d",
|
||||||
|
keywordName: "New question types",
|
||||||
|
count: 600,
|
||||||
|
description:
|
||||||
|
"Requests for additional question types in Formbricks surveys (e.g. matrix, ranking, sliders, image choice) to capture different kinds of feedback.",
|
||||||
|
themes: [
|
||||||
|
{ id: "t1", label: "Ideas", count: 450, color: "orange" },
|
||||||
|
{ id: "t2", label: "Questions", count: 100, color: "yellow" },
|
||||||
|
{ id: "t3", label: "Other", count: 50, color: "green" },
|
||||||
|
],
|
||||||
|
themeItems: [
|
||||||
|
{ id: "ti-1", label: "Matrix / grid question", count: 180, icon: "lightbulb" },
|
||||||
|
{ id: "ti-2", label: "Ranking question type", count: 120, icon: "lightbulb" },
|
||||||
|
{ id: "ti-3", label: "Slider and scale variants", count: 90, icon: "lightbulb" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDetailForL3(keywordId: string): TaxonomyDetail | null {
|
||||||
|
return MOCK_DETAIL_L3[keywordId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCount(n: number): string {
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
import { TaxonomySection } from "./components/TaxonomySection";
|
||||||
|
|
||||||
|
export default async function UnifyTaxonomyPage(props: { params: Promise<{ environmentId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
return <TaxonomySection environmentId={params.environmentId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export interface TaxonomyKeyword {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxonomyTheme {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
color: "red" | "orange" | "yellow" | "green" | "slate";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxonomyThemeItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
icon?: "warning" | "wrench" | "message-circle" | "lightbulb";
|
||||||
|
children?: TaxonomyThemeItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxonomyDetail {
|
||||||
|
keywordId: string;
|
||||||
|
keywordName: string;
|
||||||
|
count: number;
|
||||||
|
description: string;
|
||||||
|
themes: TaxonomyTheme[];
|
||||||
|
themeItems: TaxonomyThemeItem[];
|
||||||
|
}
|
||||||
@@ -21,7 +21,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
|
|||||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||||
import { truncateText } from "@/lib/utils/strings";
|
import { truncateText } from "@/lib/utils/strings";
|
||||||
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
|
|
||||||
|
|
||||||
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
||||||
let result: string[] = [];
|
let result: string[] = [];
|
||||||
@@ -257,16 +256,10 @@ const processElementResponse = (
|
|||||||
const selectedChoiceIds = responseValue as string[];
|
const selectedChoiceIds = responseValue as string[];
|
||||||
return element.choices
|
return element.choices
|
||||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||||
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
|
.map((choice) => choice.imageUrl)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
|
|
||||||
return responseValue
|
|
||||||
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
|
|
||||||
.join("; ");
|
|
||||||
}
|
|
||||||
|
|
||||||
return processResponseData(responseValue);
|
return processResponseData(responseValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -375,7 +368,7 @@ const buildNotionPayloadProperties = (
|
|||||||
|
|
||||||
responses[resp] = (pictureElement as any)?.choices
|
responses[resp] = (pictureElement as any)?.choices
|
||||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||||
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
|
.map((choice) => choice.imageUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { convertDatesInObject } from "@/lib/time";
|
|||||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
|
||||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||||
import { handleIntegrations } from "./lib/handleIntegrations";
|
import { handleIntegrations } from "./lib/handleIntegrations";
|
||||||
@@ -31,10 +30,7 @@ export const POST = async (request: Request) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const jsonInput = await request.json();
|
const jsonInput = await request.json();
|
||||||
const convertedJsonInput = convertDatesInObject(
|
const convertedJsonInput = convertDatesInObject(jsonInput);
|
||||||
jsonInput,
|
|
||||||
new Set(["contactAttributes", "variables", "data", "meta"])
|
|
||||||
);
|
|
||||||
|
|
||||||
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
|
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
|
||||||
|
|
||||||
@@ -96,15 +92,12 @@ export const POST = async (request: Request) => {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
|
|
||||||
|
|
||||||
const webhookPromises = webhooks.map((webhook) => {
|
const webhookPromises = webhooks.map((webhook) => {
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
webhookId: webhook.id,
|
webhookId: webhook.id,
|
||||||
event,
|
event,
|
||||||
data: {
|
data: {
|
||||||
...response,
|
...response,
|
||||||
data: resolvedResponseData,
|
|
||||||
survey: {
|
survey: {
|
||||||
title: survey.name,
|
title: survey.name,
|
||||||
type: survey.type,
|
type: survey.type,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { google } from "googleapis";
|
import { google } from "googleapis";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import {
|
import {
|
||||||
GOOGLE_SHEETS_CLIENT_ID,
|
GOOGLE_SHEETS_CLIENT_ID,
|
||||||
@@ -7,29 +6,18 @@ import {
|
|||||||
GOOGLE_SHEETS_REDIRECT_URL,
|
GOOGLE_SHEETS_REDIRECT_URL,
|
||||||
WEBAPP_URL,
|
WEBAPP_URL,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
|
||||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
|
|
||||||
export const GET = async (req: Request) => {
|
export const GET = async (req: Request) => {
|
||||||
const url = new URL(req.url);
|
const url = req.url;
|
||||||
const environmentId = url.searchParams.get("state");
|
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||||
const code = url.searchParams.get("code");
|
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||||
|
const code = queryParams.get("code");
|
||||||
|
|
||||||
if (!environmentId) {
|
if (!environmentId) {
|
||||||
return responses.badRequestResponse("Invalid environmentId");
|
return responses.badRequestResponse("Invalid environmentId");
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session) {
|
|
||||||
return responses.notAuthenticatedResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
|
||||||
if (!canUserAccessEnvironment) {
|
|
||||||
return responses.unauthorizedResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code && typeof code !== "string") {
|
if (code && typeof code !== "string") {
|
||||||
return responses.badRequestResponse("`code` must be a string");
|
return responses.badRequestResponse("`code` must be a string");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Deprecated: This api route is deprecated now and will be removed in the future.
|
||||||
|
// Deprecated: This is currently only being used for the older react native SDKs. Please upgrade to the latest SDKs.
|
||||||
|
import { NextRequest, userAgent } from "next/server";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { TJsPeopleUserIdInput, ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import { getContactByUserId } from "@/app/api/v1/client/[environmentId]/app/sync/lib/contact";
|
||||||
|
import { getSyncSurveys } from "@/app/api/v1/client/[environmentId]/app/sync/lib/survey";
|
||||||
|
import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
|
||||||
|
import { responses } from "@/app/lib/api/response";
|
||||||
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
|
import { getActionClasses } from "@/lib/actionClass/service";
|
||||||
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
|
import { getEnvironment, updateEnvironment } from "@/lib/environment/service";
|
||||||
|
import {
|
||||||
|
getMonthlyOrganizationResponseCount,
|
||||||
|
getOrganizationByEnvironmentId,
|
||||||
|
} from "@/lib/organization/service";
|
||||||
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
|
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||||
|
|
||||||
|
const validateInput = (
|
||||||
|
environmentId: string,
|
||||||
|
userId: string
|
||||||
|
): { isValid: true; data: TJsPeopleUserIdInput } | { isValid: false; error: Response } => {
|
||||||
|
const inputValidation = ZJsPeopleUserIdInput.safeParse({ environmentId, userId });
|
||||||
|
if (!inputValidation.success) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: responses.badRequestResponse(
|
||||||
|
"Fields are missing or incorrectly formatted",
|
||||||
|
transformErrorToDetails(inputValidation.error),
|
||||||
|
true
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { isValid: true, data: inputValidation.data };
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
|
||||||
|
if (!IS_FORMBRICKS_CLOUD) return false;
|
||||||
|
|
||||||
|
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||||
|
if (!organization) {
|
||||||
|
logger.error({ environmentId }, "Organization does not exist");
|
||||||
|
|
||||||
|
// fail closed if the organization does not exist
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||||
|
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||||
|
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||||
|
|
||||||
|
return isLimitReached;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OPTIONS = async (): Promise<Response> => {
|
||||||
|
return responses.successResponse({}, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = withV1ApiWrapper({
|
||||||
|
handler: async ({
|
||||||
|
req,
|
||||||
|
props,
|
||||||
|
}: {
|
||||||
|
req: NextRequest;
|
||||||
|
props: { params: Promise<{ environmentId: string; userId: string }> };
|
||||||
|
}) => {
|
||||||
|
const params = await props.params;
|
||||||
|
try {
|
||||||
|
const { device } = userAgent(req);
|
||||||
|
|
||||||
|
// validate using zod
|
||||||
|
const validation = validateInput(params.environmentId, params.userId);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
return { response: validation.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { environmentId, userId } = validation.data;
|
||||||
|
|
||||||
|
const environment = await getEnvironment(environmentId);
|
||||||
|
if (!environment) {
|
||||||
|
throw new Error("Environment does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await getProjectByEnvironmentId(environmentId);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("Project not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!environment.appSetupCompleted) {
|
||||||
|
await updateEnvironment(environment.id, { appSetupCompleted: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// check organization subscriptions and response limits
|
||||||
|
const isAppSurveyResponseLimitReached = await checkResponseLimit(environmentId);
|
||||||
|
|
||||||
|
let contact = await getContactByUserId(environmentId, userId);
|
||||||
|
if (!contact) {
|
||||||
|
contact = await prisma.contact.create({
|
||||||
|
data: {
|
||||||
|
attributes: {
|
||||||
|
create: {
|
||||||
|
attributeKey: {
|
||||||
|
connect: {
|
||||||
|
key_environmentId: {
|
||||||
|
key: "userId",
|
||||||
|
environmentId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
value: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
environment: { connect: { id: environmentId } },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
|
||||||
|
acc[attribute.attributeKey.key] = attribute.value;
|
||||||
|
return acc;
|
||||||
|
}, {}) as Record<string, string>;
|
||||||
|
|
||||||
|
const [surveys, actionClasses] = await Promise.all([
|
||||||
|
getSyncSurveys(
|
||||||
|
environmentId,
|
||||||
|
contact.id,
|
||||||
|
contactAttributes,
|
||||||
|
device.type === "mobile" ? "phone" : "desktop"
|
||||||
|
),
|
||||||
|
getActionClasses(environmentId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const updatedProject: any = {
|
||||||
|
...project,
|
||||||
|
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
|
||||||
|
...(project.styling.highlightBorderColor?.light && {
|
||||||
|
highlightBorderColor: project.styling.highlightBorderColor.light,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const language = contactAttributes["language"];
|
||||||
|
|
||||||
|
// Scenario 1: Multi language and updated trigger action classes supported.
|
||||||
|
// Use the surveys as they are.
|
||||||
|
let transformedSurveys: TSurvey[] = surveys;
|
||||||
|
|
||||||
|
// creating state object
|
||||||
|
let state = {
|
||||||
|
surveys: !isAppSurveyResponseLimitReached
|
||||||
|
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
|
||||||
|
: [],
|
||||||
|
actionClasses,
|
||||||
|
language,
|
||||||
|
project: updatedProject,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: responses.successResponse({ ...state }, true),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, url: req.url }, "Error in GET /api/v1/client/[environmentId]/app/sync/[userId]");
|
||||||
|
return {
|
||||||
|
response: responses.internalServerErrorResponse(
|
||||||
|
"Unable to handle the request: " + error.message,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { TContact } from "@/modules/ee/contacts/types/contact";
|
||||||
|
import { getContactByUserId } from "./contact";
|
||||||
|
|
||||||
|
// Mock prisma
|
||||||
|
vi.mock("@formbricks/database", () => ({
|
||||||
|
prisma: {
|
||||||
|
contact: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const environmentId = "test-environment-id";
|
||||||
|
const userId = "test-user-id";
|
||||||
|
const contactId = "test-contact-id";
|
||||||
|
|
||||||
|
const contactMock: Partial<TContact> & {
|
||||||
|
attributes: { value: string; attributeKey: { key: string } }[];
|
||||||
|
} = {
|
||||||
|
id: contactId,
|
||||||
|
attributes: [
|
||||||
|
{ attributeKey: { key: "userId" }, value: userId },
|
||||||
|
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("getContactByUserId", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return contact if found", async () => {
|
||||||
|
vi.mocked(prisma.contact.findFirst).mockResolvedValue(contactMock as any);
|
||||||
|
|
||||||
|
const contact = await getContactByUserId(environmentId, userId);
|
||||||
|
|
||||||
|
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: {
|
||||||
|
key: "userId",
|
||||||
|
environmentId,
|
||||||
|
},
|
||||||
|
value: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(contact).toEqual(contactMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return null if contact not found", async () => {
|
||||||
|
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const contact = await getContactByUserId(environmentId, userId);
|
||||||
|
|
||||||
|
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: {
|
||||||
|
key: "userId",
|
||||||
|
environmentId,
|
||||||
|
},
|
||||||
|
value: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(contact).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { cache as reactCache } from "react";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
|
||||||
|
export const getContactByUserId = reactCache(
|
||||||
|
async (
|
||||||
|
environmentId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<{
|
||||||
|
attributes: {
|
||||||
|
value: string;
|
||||||
|
attributeKey: {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
id: string;
|
||||||
|
} | null> => {
|
||||||
|
const contact = await prisma.contact.findFirst({
|
||||||
|
where: {
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: {
|
||||||
|
key: "userId",
|
||||||
|
environmentId,
|
||||||
|
},
|
||||||
|
value: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
|
import { TProject } from "@formbricks/types/project";
|
||||||
|
import { TSegment } from "@formbricks/types/segment";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
|
import { getSurveys } from "@/lib/survey/service";
|
||||||
|
import { anySurveyHasFilters } from "@/lib/survey/utils";
|
||||||
|
import { diffInDays } from "@/lib/utils/datetime";
|
||||||
|
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||||
|
import { getSyncSurveys } from "./survey";
|
||||||
|
|
||||||
|
vi.mock("@/lib/project/service", () => ({
|
||||||
|
getProjectByEnvironmentId: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/survey/service", () => ({
|
||||||
|
getSurveys: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/survey/utils", () => ({
|
||||||
|
anySurveyHasFilters: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/utils/datetime", () => ({
|
||||||
|
diffInDays: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/utils/validate", () => ({
|
||||||
|
validateInputs: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
|
||||||
|
evaluateSegment: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@formbricks/database", () => ({
|
||||||
|
prisma: {
|
||||||
|
display: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@formbricks/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const environmentId = "test-env-id";
|
||||||
|
const contactId = "test-contact-id";
|
||||||
|
const contactAttributes = { userId: "user1", email: "test@example.com" };
|
||||||
|
const deviceType = "desktop";
|
||||||
|
|
||||||
|
const mockProject = {
|
||||||
|
id: "proj1",
|
||||||
|
name: "Test Project",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
organizationId: "org1",
|
||||||
|
environments: [],
|
||||||
|
recontactDays: 10,
|
||||||
|
inAppSurveyBranding: true,
|
||||||
|
linkSurveyBranding: true,
|
||||||
|
placement: "bottomRight",
|
||||||
|
clickOutsideClose: true,
|
||||||
|
darkOverlay: false,
|
||||||
|
languages: [],
|
||||||
|
} as unknown as TProject;
|
||||||
|
|
||||||
|
const baseSurvey: TSurvey = {
|
||||||
|
id: "survey1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
name: "Test Survey 1",
|
||||||
|
environmentId: environmentId,
|
||||||
|
type: "app",
|
||||||
|
status: "inProgress",
|
||||||
|
questions: [],
|
||||||
|
displayOption: "displayOnce",
|
||||||
|
recontactDays: null,
|
||||||
|
autoClose: null,
|
||||||
|
delay: 0,
|
||||||
|
displayPercentage: null,
|
||||||
|
autoComplete: null,
|
||||||
|
segment: null,
|
||||||
|
surveyClosedMessage: null,
|
||||||
|
singleUse: null,
|
||||||
|
styling: null,
|
||||||
|
pin: null,
|
||||||
|
displayLimit: null,
|
||||||
|
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||||
|
endings: [],
|
||||||
|
triggers: [],
|
||||||
|
languages: [],
|
||||||
|
variables: [],
|
||||||
|
hiddenFields: { enabled: false },
|
||||||
|
createdBy: null,
|
||||||
|
isSingleResponsePerEmailEnabled: false,
|
||||||
|
isVerifyEmailEnabled: false,
|
||||||
|
projectOverwrites: null,
|
||||||
|
showLanguageSwitch: false,
|
||||||
|
isBackButtonHidden: false,
|
||||||
|
followUps: [],
|
||||||
|
recaptcha: { enabled: false, threshold: 0.5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to create mock display objects
|
||||||
|
const createMockDisplay = (id: string, surveyId: string, contactId: string, createdAt?: Date) => ({
|
||||||
|
id,
|
||||||
|
createdAt: createdAt || new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
surveyId,
|
||||||
|
contactId,
|
||||||
|
responseId: null,
|
||||||
|
status: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to create mock response objects
|
||||||
|
const createMockResponse = (id: string, surveyId: string, contactId: string, createdAt?: Date) => ({
|
||||||
|
id,
|
||||||
|
createdAt: createdAt || new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
finished: false,
|
||||||
|
surveyId,
|
||||||
|
contactId,
|
||||||
|
endingId: null,
|
||||||
|
data: {},
|
||||||
|
variables: {},
|
||||||
|
ttc: {},
|
||||||
|
meta: {},
|
||||||
|
contactAttributes: null,
|
||||||
|
singleUseId: null,
|
||||||
|
language: null,
|
||||||
|
displayId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSyncSurveys", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
|
||||||
|
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||||
|
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
|
||||||
|
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
|
||||||
|
vi.mocked(evaluateSegment).mockResolvedValue(true);
|
||||||
|
vi.mocked(diffInDays).mockReturnValue(100); // Assume enough days passed
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw error if product not found", async () => {
|
||||||
|
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null);
|
||||||
|
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
|
||||||
|
"Project not found"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty array if no surveys found", async () => {
|
||||||
|
vi.mocked(getSurveys).mockResolvedValue([]);
|
||||||
|
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty array if no 'app' type surveys in progress", async () => {
|
||||||
|
const surveys: TSurvey[] = [
|
||||||
|
{ ...baseSurvey, id: "s1", type: "link", status: "inProgress" },
|
||||||
|
{ ...baseSurvey, id: "s2", type: "app", status: "paused" },
|
||||||
|
];
|
||||||
|
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||||
|
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should filter by displayOption 'displayOnce'", async () => {
|
||||||
|
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayOnce" }];
|
||||||
|
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||||
|
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); // Already displayed
|
||||||
|
|
||||||
|
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
|
||||||
|
vi.mocked(prisma.display.findMany).mockResolvedValue([]); // Not displayed yet
|
||||||
|
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result2).toEqual(surveys);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should filter by displayOption 'displayMultiple'", async () => {
|
||||||
|
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayMultiple" }];
|
||||||
|
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||||
|
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]); // Already responded
|
||||||
|
|
||||||
|
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
|
||||||
|
vi.mocked(prisma.response.findMany).mockResolvedValue([]); // Not responded yet
|
||||||
|
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result2).toEqual(surveys);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should filter by displayOption 'displaySome'", async () => {
|
||||||
|
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displaySome", displayLimit: 2 }];
|
||||||
|
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||||
|
vi.mocked(prisma.display.findMany).mockResolvedValue([
|
||||||
|
createMockDisplay("d1", "s1", contactId),
|
||||||
|
createMockDisplay("d2", "s1", contactId),
|
||||||
|
]); // Display limit reached
|
||||||
|
|
||||||
|
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
|
||||||
|
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); // Within limit
|
||||||
|
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result2).toEqual(surveys);
|
||||||
|
|
||||||
|
// Test with response already submitted
|
||||||
|
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]);
|
||||||
|
const result3 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result3).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not filter by displayOption 'respondMultiple'", async () => {
|
||||||
|
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "respondMultiple" }];
|
||||||
|
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||||
|
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]);
|
||||||
|
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]);
|
||||||
|
|
||||||
|
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result).toEqual(surveys);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should filter by product recontactDays if survey recontactDays is null", async () => {
|
||||||
|
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", recontactDays: null }];
|
||||||
|
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||||
|
const displayDate = new Date();
|
||||||
|
vi.mocked(prisma.display.findMany).mockResolvedValue([
|
||||||
|
createMockDisplay("d1", "s2", contactId, displayDate), // Display for another survey
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.mocked(diffInDays).mockReturnValue(5); // Not enough days passed (product.recontactDays = 10)
|
||||||
|
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(diffInDays).toHaveBeenCalledWith(expect.any(Date), displayDate);
|
||||||
|
|
||||||
|
vi.mocked(diffInDays).mockReturnValue(15); // Enough days passed
|
||||||
|
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result2).toEqual(surveys);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return surveys if no segment filters exist", async () => {
|
||||||
|
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1" }];
|
||||||
|
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||||
|
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result).toEqual(surveys);
|
||||||
|
expect(evaluateSegment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should evaluate segment filters if they exist", async () => {
|
||||||
|
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
|
||||||
|
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
|
||||||
|
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||||
|
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
|
||||||
|
|
||||||
|
// Case 1: Segment evaluation matches
|
||||||
|
vi.mocked(evaluateSegment).mockResolvedValue(true);
|
||||||
|
const result1 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result1).toEqual(surveys);
|
||||||
|
expect(evaluateSegment).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
attributes: contactAttributes,
|
||||||
|
deviceType,
|
||||||
|
environmentId,
|
||||||
|
contactId,
|
||||||
|
userId: contactAttributes.userId,
|
||||||
|
},
|
||||||
|
segment.filters
|
||||||
|
);
|
||||||
|
|
||||||
|
// Case 2: Segment evaluation does not match
|
||||||
|
vi.mocked(evaluateSegment).mockResolvedValue(false);
|
||||||
|
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result2).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle Prisma errors", async () => {
|
||||||
|
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||||
|
code: "P2025",
|
||||||
|
clientVersion: "test",
|
||||||
|
});
|
||||||
|
vi.mocked(getSurveys).mockRejectedValue(prismaError);
|
||||||
|
|
||||||
|
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
|
||||||
|
DatabaseError
|
||||||
|
);
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(prismaError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle general errors", async () => {
|
||||||
|
const generalError = new Error("Something went wrong");
|
||||||
|
vi.mocked(getSurveys).mockRejectedValue(generalError);
|
||||||
|
|
||||||
|
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
|
||||||
|
generalError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw ResourceNotFoundError if resolved surveys are null after filtering", async () => {
|
||||||
|
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
|
||||||
|
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
|
||||||
|
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||||
|
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
|
||||||
|
vi.mocked(evaluateSegment).mockResolvedValue(false); // Ensure all surveys are filtered out
|
||||||
|
|
||||||
|
// This scenario is tricky to force directly as the code checks `if (!surveys)` before returning.
|
||||||
|
// However, if `Promise.all` somehow resolved to null/undefined (highly unlikely), it should throw.
|
||||||
|
// We can simulate this by mocking `Promise.all` if needed, but the current code structure makes this hard to test.
|
||||||
|
// Let's assume the filter logic works correctly and test the intended path.
|
||||||
|
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||||
|
expect(result).toEqual([]); // Expect empty array, not an error in this case.
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { cache as reactCache } from "react";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { ZId } from "@formbricks/types/common";
|
||||||
|
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
|
import { getSurveys } from "@/lib/survey/service";
|
||||||
|
import { anySurveyHasFilters } from "@/lib/survey/utils";
|
||||||
|
import { diffInDays } from "@/lib/utils/datetime";
|
||||||
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
|
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||||
|
|
||||||
|
export const getSyncSurveys = reactCache(
|
||||||
|
async (
|
||||||
|
environmentId: string,
|
||||||
|
contactId: string,
|
||||||
|
contactAttributes: Record<string, string | number>,
|
||||||
|
deviceType: "phone" | "desktop" = "desktop"
|
||||||
|
): Promise<TSurvey[]> => {
|
||||||
|
validateInputs([environmentId, ZId]);
|
||||||
|
try {
|
||||||
|
const project = await getProjectByEnvironmentId(environmentId);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("Project not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
let surveys = await getSurveys(environmentId);
|
||||||
|
|
||||||
|
// filtered surveys for running and web
|
||||||
|
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "app");
|
||||||
|
|
||||||
|
// if no surveys are left, return an empty array
|
||||||
|
if (surveys.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const displays = await prisma.display.findMany({
|
||||||
|
where: {
|
||||||
|
contactId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const responses = await prisma.response.findMany({
|
||||||
|
where: {
|
||||||
|
contactId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// filter surveys that meet the displayOption criteria
|
||||||
|
surveys = surveys.filter((survey) => {
|
||||||
|
switch (survey.displayOption) {
|
||||||
|
case "respondMultiple":
|
||||||
|
return true;
|
||||||
|
case "displayOnce":
|
||||||
|
return displays.filter((display) => display.surveyId === survey.id).length === 0;
|
||||||
|
case "displayMultiple":
|
||||||
|
if (!responses) return true;
|
||||||
|
else {
|
||||||
|
return responses.filter((response) => response.surveyId === survey.id).length === 0;
|
||||||
|
}
|
||||||
|
case "displaySome":
|
||||||
|
if (survey.displayLimit === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responses && responses.filter((response) => response.surveyId === survey.id).length !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit;
|
||||||
|
default:
|
||||||
|
throw Error("Invalid displayOption");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestDisplay = displays[0];
|
||||||
|
|
||||||
|
// filter surveys that meet the recontactDays criteria
|
||||||
|
surveys = surveys.filter((survey) => {
|
||||||
|
if (!latestDisplay) {
|
||||||
|
return true;
|
||||||
|
} else if (survey.recontactDays !== null) {
|
||||||
|
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
|
||||||
|
if (!lastDisplaySurvey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
|
||||||
|
} else if (project.recontactDays !== null) {
|
||||||
|
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= project.recontactDays;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// if no surveys are left, return an empty array
|
||||||
|
if (surveys.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// if no surveys have segment filters, return the surveys
|
||||||
|
if (!anySurveyHasFilters(surveys)) {
|
||||||
|
return surveys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the surveys now have segment filters, so we need to evaluate them
|
||||||
|
const surveyPromises = surveys.map(async (survey) => {
|
||||||
|
const { segment } = survey;
|
||||||
|
// if the survey has no segment, or the segment has no filters, we return the survey
|
||||||
|
if (!segment || !segment.filters?.length) {
|
||||||
|
return survey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate the segment filters
|
||||||
|
const result = await evaluateSegment(
|
||||||
|
{
|
||||||
|
attributes: contactAttributes ?? {},
|
||||||
|
deviceType,
|
||||||
|
environmentId,
|
||||||
|
contactId,
|
||||||
|
userId: String(contactAttributes.userId),
|
||||||
|
},
|
||||||
|
segment.filters
|
||||||
|
);
|
||||||
|
|
||||||
|
return result ? survey : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedSurveys = await Promise.all(surveyPromises);
|
||||||
|
surveys = resolvedSurveys.filter((survey) => !!survey) as TSurvey[];
|
||||||
|
|
||||||
|
if (!surveys) {
|
||||||
|
throw new ResourceNotFoundError("Survey", environmentId);
|
||||||
|
}
|
||||||
|
return surveys;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
logger.error(error);
|
||||||
|
throw new DatabaseError(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import { TAttributes } from "@formbricks/types/attributes";
|
||||||
|
import { TLanguage } from "@formbricks/types/project";
|
||||||
|
import {
|
||||||
|
TSurvey,
|
||||||
|
TSurveyEnding,
|
||||||
|
TSurveyQuestion,
|
||||||
|
TSurveyQuestionTypeEnum,
|
||||||
|
} from "@formbricks/types/surveys/types";
|
||||||
|
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||||
|
import { replaceAttributeRecall } from "./utils";
|
||||||
|
|
||||||
|
vi.mock("@/lib/utils/recall", () => ({
|
||||||
|
parseRecallInfo: vi.fn((text, attributes) => {
|
||||||
|
const recallPattern = /recall:([a-zA-Z0-9_-]+)/;
|
||||||
|
const match = text.match(recallPattern);
|
||||||
|
if (match && match[1]) {
|
||||||
|
const recallKey = match[1];
|
||||||
|
const attributeValue = attributes[recallKey];
|
||||||
|
if (attributeValue !== undefined) {
|
||||||
|
return text.replace(recallPattern, `parsed-${attributeValue}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text; // Return original text if no match or attribute not found
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseSurvey: TSurvey = {
|
||||||
|
id: "survey1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
name: "Test Survey",
|
||||||
|
environmentId: "env1",
|
||||||
|
type: "app",
|
||||||
|
status: "inProgress",
|
||||||
|
questions: [],
|
||||||
|
endings: [],
|
||||||
|
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||||
|
languages: [
|
||||||
|
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
|
||||||
|
],
|
||||||
|
triggers: [],
|
||||||
|
recontactDays: null,
|
||||||
|
displayLimit: null,
|
||||||
|
singleUse: null,
|
||||||
|
styling: null,
|
||||||
|
surveyClosedMessage: null,
|
||||||
|
hiddenFields: { enabled: false },
|
||||||
|
variables: [],
|
||||||
|
createdBy: null,
|
||||||
|
isSingleResponsePerEmailEnabled: false,
|
||||||
|
isVerifyEmailEnabled: false,
|
||||||
|
projectOverwrites: null,
|
||||||
|
showLanguageSwitch: false,
|
||||||
|
isBackButtonHidden: false,
|
||||||
|
followUps: [],
|
||||||
|
recaptcha: { enabled: false, threshold: 0.5 },
|
||||||
|
displayOption: "displayOnce",
|
||||||
|
autoClose: null,
|
||||||
|
delay: 0,
|
||||||
|
displayPercentage: null,
|
||||||
|
autoComplete: null,
|
||||||
|
segment: null,
|
||||||
|
pin: null,
|
||||||
|
metadata: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const attributes: TAttributes = {
|
||||||
|
name: "John Doe",
|
||||||
|
email: "john.doe@example.com",
|
||||||
|
plan: "premium",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("replaceAttributeRecall", () => {
|
||||||
|
test("should replace recall info in question headlines and subheaders", () => {
|
||||||
|
const surveyWithRecall: TSurvey = {
|
||||||
|
...baseSurvey,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
|
headline: { default: "Hello recall:name!" },
|
||||||
|
subheader: { default: "Your email is recall:email" },
|
||||||
|
required: true,
|
||||||
|
buttonLabel: { default: "Next" },
|
||||||
|
placeholder: { default: "Type here..." },
|
||||||
|
longAnswer: false,
|
||||||
|
logic: [],
|
||||||
|
} as unknown as TSurveyQuestion,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||||
|
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
|
||||||
|
expect(result.questions[0].subheader?.default).toBe("Your email is parsed-john.doe@example.com");
|
||||||
|
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
|
||||||
|
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your email is recall:email", attributes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should replace recall info in welcome card headline", () => {
|
||||||
|
const surveyWithRecall: TSurvey = {
|
||||||
|
...baseSurvey,
|
||||||
|
welcomeCard: {
|
||||||
|
enabled: true,
|
||||||
|
headline: { default: "Welcome, recall:name!" },
|
||||||
|
subheader: { default: "<p>Some content</p>" },
|
||||||
|
buttonLabel: { default: "Start" },
|
||||||
|
timeToFinish: false,
|
||||||
|
showResponseCount: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||||
|
expect(result.welcomeCard.headline?.default).toBe("Welcome, parsed-John Doe!");
|
||||||
|
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Welcome, recall:name!", attributes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should replace recall info in end screen headlines and subheaders", () => {
|
||||||
|
const surveyWithRecall: TSurvey = {
|
||||||
|
...baseSurvey,
|
||||||
|
endings: [
|
||||||
|
{
|
||||||
|
type: "endScreen",
|
||||||
|
headline: { default: "Thank you, recall:name!" },
|
||||||
|
subheader: { default: "Your plan: recall:plan" },
|
||||||
|
buttonLabel: { default: "Finish" },
|
||||||
|
buttonLink: "https://example.com",
|
||||||
|
} as unknown as TSurveyEnding,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||||
|
expect(result.endings[0].type).toBe("endScreen");
|
||||||
|
if (result.endings[0].type === "endScreen") {
|
||||||
|
expect(result.endings[0].headline?.default).toBe("Thank you, parsed-John Doe!");
|
||||||
|
expect(result.endings[0].subheader?.default).toBe("Your plan: parsed-premium");
|
||||||
|
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Thank you, recall:name!", attributes);
|
||||||
|
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your plan: recall:plan", attributes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle multiple languages", () => {
|
||||||
|
const surveyMultiLang: TSurvey = {
|
||||||
|
...baseSurvey,
|
||||||
|
languages: [
|
||||||
|
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
|
||||||
|
{ language: { id: "lang2", code: "es" } as unknown as TLanguage, default: false, enabled: true },
|
||||||
|
],
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
|
headline: { default: "Hello recall:name!", es: "Hola recall:name!" },
|
||||||
|
required: true,
|
||||||
|
buttonLabel: { default: "Next", es: "Siguiente" },
|
||||||
|
placeholder: { default: "Type here...", es: "Escribe aquí..." },
|
||||||
|
longAnswer: false,
|
||||||
|
logic: [],
|
||||||
|
} as unknown as TSurveyQuestion,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = replaceAttributeRecall(surveyMultiLang, attributes);
|
||||||
|
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
|
||||||
|
expect(result.questions[0].headline.es).toBe("Hola parsed-John Doe!");
|
||||||
|
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
|
||||||
|
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hola recall:name!", attributes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not replace if recall key is not in attributes", () => {
|
||||||
|
const surveyWithRecall: TSurvey = {
|
||||||
|
...baseSurvey,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
|
headline: { default: "Your company: recall:company" },
|
||||||
|
required: true,
|
||||||
|
buttonLabel: { default: "Next" },
|
||||||
|
placeholder: { default: "Type here..." },
|
||||||
|
longAnswer: false,
|
||||||
|
logic: [],
|
||||||
|
} as unknown as TSurveyQuestion,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||||
|
expect(result.questions[0].headline.default).toBe("Your company: recall:company");
|
||||||
|
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your company: recall:company", attributes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle surveys with no recall information", async () => {
|
||||||
|
const surveyNoRecall: TSurvey = {
|
||||||
|
...baseSurvey,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
|
headline: { default: "Just a regular question" },
|
||||||
|
required: true,
|
||||||
|
buttonLabel: { default: "Next" },
|
||||||
|
placeholder: { default: "Type here..." },
|
||||||
|
longAnswer: false,
|
||||||
|
logic: [],
|
||||||
|
} as unknown as TSurveyQuestion,
|
||||||
|
],
|
||||||
|
welcomeCard: {
|
||||||
|
enabled: true,
|
||||||
|
headline: { default: "Welcome!" },
|
||||||
|
subheader: { default: "<p>Some content</p>" },
|
||||||
|
buttonLabel: { default: "Start" },
|
||||||
|
timeToFinish: false,
|
||||||
|
showResponseCount: false,
|
||||||
|
},
|
||||||
|
endings: [
|
||||||
|
{
|
||||||
|
type: "endScreen",
|
||||||
|
headline: { default: "Thank you!" },
|
||||||
|
buttonLabel: { default: "Finish" },
|
||||||
|
} as unknown as TSurveyEnding,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
|
||||||
|
|
||||||
|
const result = replaceAttributeRecall(surveyNoRecall, attributes);
|
||||||
|
expect(result).toEqual(surveyNoRecall); // Should be unchanged
|
||||||
|
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
|
||||||
|
parseRecallInfoSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle surveys with empty questions, endings, or disabled welcome card", async () => {
|
||||||
|
const surveyEmpty: TSurvey = {
|
||||||
|
...baseSurvey,
|
||||||
|
questions: [],
|
||||||
|
endings: [],
|
||||||
|
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||||
|
};
|
||||||
|
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
|
||||||
|
|
||||||
|
const result = replaceAttributeRecall(surveyEmpty, attributes);
|
||||||
|
expect(result).toEqual(surveyEmpty);
|
||||||
|
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
|
||||||
|
parseRecallInfoSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { TAttributes } from "@formbricks/types/attributes";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||||
|
|
||||||
|
export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes): TSurvey => {
|
||||||
|
const surveyTemp = structuredClone(survey);
|
||||||
|
const languages = surveyTemp.languages
|
||||||
|
.map((surveyLanguage) => {
|
||||||
|
if (surveyLanguage.default) {
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (surveyLanguage.enabled) {
|
||||||
|
return surveyLanguage.language.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((language): language is string => language !== null);
|
||||||
|
|
||||||
|
surveyTemp.questions.forEach((question) => {
|
||||||
|
languages.forEach((language) => {
|
||||||
|
if (question.headline[language]?.includes("recall:")) {
|
||||||
|
question.headline[language] = parseRecallInfo(question.headline[language], attributes);
|
||||||
|
}
|
||||||
|
if (question.subheader && question.subheader[language]?.includes("recall:")) {
|
||||||
|
question.subheader[language] = parseRecallInfo(question.subheader[language], attributes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (surveyTemp.welcomeCard.enabled && surveyTemp.welcomeCard.headline) {
|
||||||
|
languages.forEach((language) => {
|
||||||
|
if (surveyTemp.welcomeCard.headline && surveyTemp.welcomeCard.headline[language]?.includes("recall:")) {
|
||||||
|
surveyTemp.welcomeCard.headline[language] = parseRecallInfo(
|
||||||
|
surveyTemp.welcomeCard.headline[language],
|
||||||
|
attributes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
surveyTemp.endings.forEach((ending) => {
|
||||||
|
if (ending.type === "endScreen") {
|
||||||
|
languages.forEach((language) => {
|
||||||
|
if (ending.headline && ending.headline[language]?.includes("recall:")) {
|
||||||
|
ending.headline[language] = parseRecallInfo(ending.headline[language], attributes);
|
||||||
|
if (ending.subheader && ending.subheader[language]?.includes("recall:")) {
|
||||||
|
ending.subheader[language] = parseRecallInfo(ending.subheader[language], attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return surveyTemp;
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import {
|
||||||
|
OPTIONS,
|
||||||
|
PUT,
|
||||||
|
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
|
||||||
|
|
||||||
|
export { OPTIONS, PUT };
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { prisma } from "@formbricks/database";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getEnvironmentStateData } from "./data";
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
|
||||||
prisma: {
|
|
||||||
environment: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@formbricks/logger", () => ({
|
|
||||||
logger: {
|
|
||||||
error: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/survey/lib/utils", () => ({
|
|
||||||
transformPrismaSurvey: vi.fn((survey) => survey),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const environmentId = "cjld2cjxh0000qzrmn831i7rn";
|
|
||||||
|
|
||||||
const mockEnvironmentData = {
|
|
||||||
id: environmentId,
|
|
||||||
type: "production",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
project: {
|
|
||||||
id: "project-123",
|
|
||||||
recontactDays: 30,
|
|
||||||
clickOutsideClose: true,
|
|
||||||
overlay: "none",
|
|
||||||
placement: "bottomRight",
|
|
||||||
inAppSurveyBranding: true,
|
|
||||||
styling: { allowStyleOverwrite: false },
|
|
||||||
organization: {
|
|
||||||
id: "org-123",
|
|
||||||
billing: {
|
|
||||||
plan: "free",
|
|
||||||
limits: { monthly: { responses: 100 } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
actionClasses: [
|
|
||||||
{
|
|
||||||
id: "action-1",
|
|
||||||
type: "code",
|
|
||||||
name: "Test Action",
|
|
||||||
key: "test-action",
|
|
||||||
noCodeConfig: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
surveys: [
|
|
||||||
{
|
|
||||||
id: "survey-1",
|
|
||||||
name: "Test Survey",
|
|
||||||
type: "app",
|
|
||||||
status: "inProgress",
|
|
||||||
welcomeCard: { enabled: false },
|
|
||||||
questions: [],
|
|
||||||
blocks: null,
|
|
||||||
variables: [],
|
|
||||||
showLanguageSwitch: false,
|
|
||||||
languages: [],
|
|
||||||
endings: [],
|
|
||||||
autoClose: null,
|
|
||||||
styling: null,
|
|
||||||
recaptcha: { enabled: false },
|
|
||||||
segment: null,
|
|
||||||
recontactDays: null,
|
|
||||||
displayLimit: null,
|
|
||||||
displayOption: "displayOnce",
|
|
||||||
hiddenFields: { enabled: false },
|
|
||||||
isBackButtonHidden: false,
|
|
||||||
triggers: [],
|
|
||||||
displayPercentage: null,
|
|
||||||
delay: 0,
|
|
||||||
projectOverwrites: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("getEnvironmentStateData", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return environment state data when environment exists", async () => {
|
|
||||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironmentData as never);
|
|
||||||
|
|
||||||
const result = await getEnvironmentStateData(environmentId);
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
environment: {
|
|
||||||
id: environmentId,
|
|
||||||
type: "production",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
project: {
|
|
||||||
id: "project-123",
|
|
||||||
recontactDays: 30,
|
|
||||||
clickOutsideClose: true,
|
|
||||||
overlay: "none",
|
|
||||||
placement: "bottomRight",
|
|
||||||
inAppSurveyBranding: true,
|
|
||||||
styling: { allowStyleOverwrite: false },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
organization: {
|
|
||||||
id: "org-123",
|
|
||||||
billing: {
|
|
||||||
plan: "free",
|
|
||||||
limits: { monthly: { responses: 100 } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
surveys: mockEnvironmentData.surveys,
|
|
||||||
actionClasses: mockEnvironmentData.actionClasses,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
|
|
||||||
where: { id: environmentId },
|
|
||||||
select: expect.objectContaining({
|
|
||||||
id: true,
|
|
||||||
type: true,
|
|
||||||
appSetupCompleted: true,
|
|
||||||
project: expect.any(Object),
|
|
||||||
actionClasses: expect.any(Object),
|
|
||||||
surveys: expect.any(Object),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw ResourceNotFoundError when environment is not found", async () => {
|
|
||||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
|
|
||||||
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("environment");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw ResourceNotFoundError when project is not found", async () => {
|
|
||||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
|
|
||||||
...mockEnvironmentData,
|
|
||||||
project: null,
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw ResourceNotFoundError when organization is not found", async () => {
|
|
||||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
|
|
||||||
...mockEnvironmentData,
|
|
||||||
project: {
|
|
||||||
...mockEnvironmentData.project,
|
|
||||||
organization: null,
|
|
||||||
},
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw DatabaseError on Prisma database errors", async () => {
|
|
||||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Connection failed", {
|
|
||||||
code: "P2024",
|
|
||||||
clientVersion: "5.0.0",
|
|
||||||
});
|
|
||||||
vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError);
|
|
||||||
|
|
||||||
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(DatabaseError);
|
|
||||||
expect(logger.error).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should rethrow unexpected errors", async () => {
|
|
||||||
const unexpectedError = new Error("Unexpected error");
|
|
||||||
vi.mocked(prisma.environment.findUnique).mockRejectedValue(unexpectedError);
|
|
||||||
|
|
||||||
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("Unexpected error");
|
|
||||||
expect(logger.error).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle empty surveys array", async () => {
|
|
||||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
|
|
||||||
...mockEnvironmentData,
|
|
||||||
surveys: [],
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const result = await getEnvironmentStateData(environmentId);
|
|
||||||
|
|
||||||
expect(result.surveys).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle empty actionClasses array", async () => {
|
|
||||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
|
|
||||||
...mockEnvironmentData,
|
|
||||||
actionClasses: [],
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const result = await getEnvironmentStateData(environmentId);
|
|
||||||
|
|
||||||
expect(result.actionClasses).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should transform surveys using transformPrismaSurvey", async () => {
|
|
||||||
const multipleSurveys = [
|
|
||||||
...mockEnvironmentData.surveys,
|
|
||||||
{
|
|
||||||
...mockEnvironmentData.surveys[0],
|
|
||||||
id: "survey-2",
|
|
||||||
name: "Second Survey",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
|
|
||||||
...mockEnvironmentData,
|
|
||||||
surveys: multipleSurveys,
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const result = await getEnvironmentStateData(environmentId);
|
|
||||||
|
|
||||||
expect(result.surveys).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should correctly map project properties to environment.project", async () => {
|
|
||||||
const customProject = {
|
|
||||||
...mockEnvironmentData.project,
|
|
||||||
recontactDays: 14,
|
|
||||||
clickOutsideClose: false,
|
|
||||||
overlay: "dark",
|
|
||||||
placement: "center",
|
|
||||||
inAppSurveyBranding: false,
|
|
||||||
styling: { allowStyleOverwrite: true, brandColor: "#ff0000" },
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
|
|
||||||
...mockEnvironmentData,
|
|
||||||
project: customProject,
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const result = await getEnvironmentStateData(environmentId);
|
|
||||||
|
|
||||||
expect(result.environment.project).toEqual({
|
|
||||||
id: "project-123",
|
|
||||||
recontactDays: 14,
|
|
||||||
clickOutsideClose: false,
|
|
||||||
overlay: "dark",
|
|
||||||
placement: "center",
|
|
||||||
inAppSurveyBranding: false,
|
|
||||||
styling: { allowStyleOverwrite: true, brandColor: "#ff0000" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should validate environmentId input", async () => {
|
|
||||||
// Invalid CUID should throw validation error
|
|
||||||
await expect(getEnvironmentStateData("invalid-id")).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle different environment types", async () => {
|
|
||||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
|
|
||||||
...mockEnvironmentData,
|
|
||||||
type: "development",
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const result = await getEnvironmentStateData(environmentId);
|
|
||||||
|
|
||||||
expect(result.environment.type).toBe("development");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle appSetupCompleted false", async () => {
|
|
||||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
|
|
||||||
...mockEnvironmentData,
|
|
||||||
appSetupCompleted: false,
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const result = await getEnvironmentStateData(environmentId);
|
|
||||||
|
|
||||||
expect(result.environment.appSetupCompleted).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should correctly extract organization billing data", async () => {
|
|
||||||
const customBilling = {
|
|
||||||
plan: "enterprise",
|
|
||||||
stripeCustomerId: "cus_123",
|
|
||||||
limits: {
|
|
||||||
monthly: { responses: 10000, miu: 50000 },
|
|
||||||
projects: 100,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
|
|
||||||
...mockEnvironmentData,
|
|
||||||
project: {
|
|
||||||
...mockEnvironmentData.project,
|
|
||||||
organization: {
|
|
||||||
id: "org-enterprise",
|
|
||||||
billing: customBilling,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const result = await getEnvironmentStateData(environmentId);
|
|
||||||
|
|
||||||
expect(result.organization).toEqual({
|
|
||||||
id: "org-enterprise",
|
|
||||||
billing: customBilling,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
TJsEnvironmentStateSurvey,
|
TJsEnvironmentStateSurvey,
|
||||||
} from "@formbricks/types/js";
|
} from "@formbricks/types/js";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
|
||||||
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
|
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,7 +54,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
|||||||
id: true,
|
id: true,
|
||||||
recontactDays: true,
|
recontactDays: true,
|
||||||
clickOutsideClose: true,
|
clickOutsideClose: true,
|
||||||
overlay: true,
|
darkOverlay: true,
|
||||||
placement: true,
|
placement: true,
|
||||||
inAppSurveyBranding: true,
|
inAppSurveyBranding: true,
|
||||||
styling: true,
|
styling: true,
|
||||||
@@ -175,17 +174,17 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
|||||||
id: environmentData.project.id,
|
id: environmentData.project.id,
|
||||||
recontactDays: environmentData.project.recontactDays,
|
recontactDays: environmentData.project.recontactDays,
|
||||||
clickOutsideClose: environmentData.project.clickOutsideClose,
|
clickOutsideClose: environmentData.project.clickOutsideClose,
|
||||||
overlay: environmentData.project.overlay,
|
darkOverlay: environmentData.project.darkOverlay,
|
||||||
placement: environmentData.project.placement,
|
placement: environmentData.project.placement,
|
||||||
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
|
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
|
||||||
styling: resolveStorageUrlsInObject(environmentData.project.styling),
|
styling: environmentData.project.styling,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
organization: {
|
organization: {
|
||||||
id: environmentData.project.organization.id,
|
id: environmentData.project.organization.id,
|
||||||
billing: environmentData.project.organization.billing,
|
billing: environmentData.project.organization.billing,
|
||||||
},
|
},
|
||||||
surveys: resolveStorageUrlsInObject(transformedSurveys),
|
surveys: transformedSurveys,
|
||||||
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
|
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const mockProject: TJsEnvironmentStateProject = {
|
|||||||
inAppSurveyBranding: true,
|
inAppSurveyBranding: true,
|
||||||
placement: "bottomRight",
|
placement: "bottomRight",
|
||||||
clickOutsideClose: true,
|
clickOutsideClose: true,
|
||||||
overlay: "none",
|
darkOverlay: false,
|
||||||
styling: {
|
styling: {
|
||||||
allowStyleOverwrite: false,
|
allowStyleOverwrite: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import {
|
||||||
|
GET,
|
||||||
|
OPTIONS,
|
||||||
|
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
|
||||||
|
|
||||||
|
export { GET, OPTIONS };
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
|
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
import { getResponse } from "@/lib/response/service";
|
import { getResponse } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
|
||||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
import { validateFileUploads } from "@/modules/storage/utils";
|
||||||
@@ -33,35 +31,6 @@ const handleDatabaseError = (error: Error, url: string, endpoint: string, respon
|
|||||||
return responses.internalServerErrorResponse("Unknown error occurred", true);
|
return responses.internalServerErrorResponse("Unknown error occurred", true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateResponse = (
|
|
||||||
response: TResponse,
|
|
||||||
survey: TSurvey,
|
|
||||||
responseUpdateInput: TResponseUpdateInput
|
|
||||||
) => {
|
|
||||||
// Validate response data against validation rules
|
|
||||||
const mergedData = {
|
|
||||||
...response.data,
|
|
||||||
...responseUpdateInput.data,
|
|
||||||
};
|
|
||||||
|
|
||||||
const validationErrors = validateResponseData(
|
|
||||||
survey.blocks,
|
|
||||||
mergedData,
|
|
||||||
responseUpdateInput.language ?? response.language ?? "en",
|
|
||||||
survey.questions
|
|
||||||
);
|
|
||||||
|
|
||||||
if (validationErrors) {
|
|
||||||
return {
|
|
||||||
response: responses.badRequestResponse(
|
|
||||||
"Validation failed",
|
|
||||||
formatValidationErrorsForV1Api(validationErrors),
|
|
||||||
true
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PUT = withV1ApiWrapper({
|
export const PUT = withV1ApiWrapper({
|
||||||
handler: async ({
|
handler: async ({
|
||||||
req,
|
req,
|
||||||
@@ -144,11 +113,6 @@ export const PUT = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const validationResult = validateResponse(response, survey, inputValidation.data);
|
|
||||||
if (validationResult) {
|
|
||||||
return validationResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
// update response with quota evaluation
|
// update response with quota evaluation
|
||||||
let updatedResponse;
|
let updatedResponse;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import { ZEnvironmentId } from "@formbricks/types/environment";
|
|||||||
import { InvalidInputError } from "@formbricks/types/errors";
|
import { InvalidInputError } from "@formbricks/types/errors";
|
||||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||||
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
import { validateFileUploads } from "@/modules/storage/utils";
|
||||||
@@ -35,26 +33,6 @@ export const OPTIONS = async (): Promise<Response> => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) => {
|
|
||||||
// Validate response data against validation rules
|
|
||||||
const validationErrors = validateResponseData(
|
|
||||||
survey.blocks,
|
|
||||||
responseInputData.data,
|
|
||||||
responseInputData.language ?? "en",
|
|
||||||
survey.questions
|
|
||||||
);
|
|
||||||
|
|
||||||
if (validationErrors) {
|
|
||||||
return {
|
|
||||||
response: responses.badRequestResponse(
|
|
||||||
"Validation failed",
|
|
||||||
formatValidationErrorsForV1Api(validationErrors),
|
|
||||||
true
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const POST = withV1ApiWrapper({
|
export const POST = withV1ApiWrapper({
|
||||||
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
|
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
@@ -145,11 +123,6 @@ export const POST = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const validationResult = validateResponse(responseInputData, survey);
|
|
||||||
if (validationResult) {
|
|
||||||
return validationResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: TResponseWithQuotaFull;
|
let response: TResponseWithQuotaFull;
|
||||||
try {
|
try {
|
||||||
const meta: TResponseInput["meta"] = {
|
const meta: TResponseInput["meta"] = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ImageResponse } from "next/og";
|
import { ImageResponse } from "@vercel/og";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
export const GET = async (req: NextRequest) => {
|
export const GET = async (req: NextRequest) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import {
|
import {
|
||||||
ENCRYPTION_KEY,
|
ENCRYPTION_KEY,
|
||||||
NOTION_OAUTH_CLIENT_ID,
|
NOTION_OAUTH_CLIENT_ID,
|
||||||
@@ -10,17 +10,10 @@ import {
|
|||||||
WEBAPP_URL,
|
WEBAPP_URL,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { symmetricEncrypt } from "@/lib/crypto";
|
import { symmetricEncrypt } from "@/lib/crypto";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
|
||||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||||
|
|
||||||
export const GET = withV1ApiWrapper({
|
export const GET = withV1ApiWrapper({
|
||||||
handler: async ({
|
handler: async ({ req }: { req: NextRequest }) => {
|
||||||
req,
|
|
||||||
authentication,
|
|
||||||
}: {
|
|
||||||
req: NextRequest;
|
|
||||||
authentication: NonNullable<TSessionAuthentication>;
|
|
||||||
}) => {
|
|
||||||
const url = req.url;
|
const url = req.url;
|
||||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||||
@@ -33,13 +26,6 @@ export const GET = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
|
||||||
if (!canUserAccessEnvironment) {
|
|
||||||
return {
|
|
||||||
response: responses.unauthorizedResponse(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code && typeof code !== "string") {
|
if (code && typeof code !== "string") {
|
||||||
return {
|
return {
|
||||||
response: responses.badRequestResponse("`code` must be a string"),
|
response: responses.badRequestResponse("`code` must be a string"),
|
||||||
|
|||||||
@@ -5,19 +5,12 @@ import {
|
|||||||
TIntegrationSlackCredential,
|
TIntegrationSlackCredential,
|
||||||
} from "@formbricks/types/integration/slack";
|
} from "@formbricks/types/integration/slack";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
|
||||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||||
|
|
||||||
export const GET = withV1ApiWrapper({
|
export const GET = withV1ApiWrapper({
|
||||||
handler: async ({
|
handler: async ({ req }: { req: NextRequest }) => {
|
||||||
req,
|
|
||||||
authentication,
|
|
||||||
}: {
|
|
||||||
req: NextRequest;
|
|
||||||
authentication: NonNullable<TSessionAuthentication>;
|
|
||||||
}) => {
|
|
||||||
const url = req.url;
|
const url = req.url;
|
||||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||||
@@ -30,13 +23,6 @@ export const GET = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
|
||||||
if (!canUserAccessEnvironment) {
|
|
||||||
return {
|
|
||||||
response: responses.unauthorizedResponse(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code && typeof code !== "string") {
|
if (code && typeof code !== "string") {
|
||||||
return {
|
return {
|
||||||
response: responses.badRequestResponse("`code` must be a string"),
|
response: responses.badRequestResponse("`code` must be a string"),
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
|||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
import { deleteResponse, getResponse } from "@/lib/response/service";
|
import { deleteResponse, getResponse } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
import {
|
||||||
|
formatValidationErrorsForV1Api,
|
||||||
|
validateResponseData,
|
||||||
|
} from "@/modules/api/v2/management/responses/lib/validation";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
import { validateFileUploads } from "@/modules/storage/utils";
|
||||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||||
|
|
||||||
async function fetchAndAuthorizeResponse(
|
async function fetchAndAuthorizeResponse(
|
||||||
@@ -57,10 +60,7 @@ export const GET = withV1ApiWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse({
|
response: responses.successResponse(result.response),
|
||||||
...result.response,
|
|
||||||
data: resolveStorageUrlsInObject(result.response.data),
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -192,7 +192,7 @@ export const PUT = withV1ApiWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
|
response: responses.successResponse(updated),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
|||||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
import {
|
||||||
|
formatValidationErrorsForV1Api,
|
||||||
|
validateResponseData,
|
||||||
|
} from "@/modules/api/v2/management/responses/lib/validation";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
import { validateFileUploads } from "@/modules/storage/utils";
|
||||||
import {
|
import {
|
||||||
createResponseWithQuotaEvaluation,
|
createResponseWithQuotaEvaluation,
|
||||||
getResponses,
|
getResponses,
|
||||||
@@ -54,9 +57,7 @@ export const GET = withV1ApiWrapper({
|
|||||||
allResponses.push(...environmentResponses);
|
allResponses.push(...environmentResponses);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(
|
response: responses.successResponse(allResponses),
|
||||||
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
|||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
|
||||||
|
|
||||||
const fetchAndAuthorizeSurvey = async (
|
const fetchAndAuthorizeSurvey = async (
|
||||||
surveyId: string,
|
surveyId: string,
|
||||||
@@ -59,18 +58,16 @@ export const GET = withV1ApiWrapper({
|
|||||||
|
|
||||||
if (shouldTransformToQuestions) {
|
if (shouldTransformToQuestions) {
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(
|
response: responses.successResponse({
|
||||||
resolveStorageUrlsInObject({
|
...result.survey,
|
||||||
...result.survey,
|
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
||||||
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
blocks: [],
|
||||||
blocks: [],
|
}),
|
||||||
})
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
|
response: responses.successResponse(result.survey),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -205,12 +202,12 @@ export const PUT = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
|
response: responses.successResponse(surveyWithQuestions),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
|
response: responses.successResponse(updatedSurvey),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
|||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { createSurvey } from "@/lib/survey/service";
|
import { createSurvey } from "@/lib/survey/service";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
|
||||||
import { getSurveys } from "./lib/surveys";
|
import { getSurveys } from "./lib/surveys";
|
||||||
|
|
||||||
export const GET = withV1ApiWrapper({
|
export const GET = withV1ApiWrapper({
|
||||||
@@ -56,7 +55,7 @@ export const GET = withV1ApiWrapper({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
|
response: responses.successResponse(surveysWithQuestions),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import {
|
||||||
|
OPTIONS,
|
||||||
|
PUT,
|
||||||
|
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
|
||||||
|
|
||||||
|
export { OPTIONS, PUT };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import {
|
||||||
|
GET,
|
||||||
|
OPTIONS,
|
||||||
|
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
|
||||||
|
|
||||||
|
export { GET, OPTIONS };
|
||||||
@@ -11,7 +11,6 @@ import { sendToPipeline } from "@/app/lib/pipelines";
|
|||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
|
||||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||||
@@ -107,22 +106,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate response data against validation rules
|
|
||||||
const validationErrors = validateResponseData(
|
|
||||||
survey.blocks,
|
|
||||||
responseInputData.data,
|
|
||||||
responseInputData.language ?? "en",
|
|
||||||
survey.questions
|
|
||||||
);
|
|
||||||
|
|
||||||
if (validationErrors) {
|
|
||||||
return responses.badRequestResponse(
|
|
||||||
"Validation failed",
|
|
||||||
formatValidationErrorsForV1Api(validationErrors),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: TResponseWithQuotaFull;
|
let response: TResponseWithQuotaFull;
|
||||||
try {
|
try {
|
||||||
const meta: TResponseInputV2["meta"] = {
|
const meta: TResponseInputV2["meta"] = {
|
||||||
|
|||||||
@@ -3,9 +3,8 @@
|
|||||||
// Error components must be Client components
|
// Error components must be Client components
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type ClientErrorType, getClientErrorData, isExpectedError } from "@formbricks/types/errors";
|
import { type ClientErrorType, getClientErrorData } from "@formbricks/types/errors";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { ErrorComponent } from "@/modules/ui/components/error-component";
|
import { ErrorComponent } from "@/modules/ui/components/error-component";
|
||||||
|
|
||||||
@@ -31,13 +30,11 @@ const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) =>
|
|||||||
const errorData = getClientErrorData(error);
|
const errorData = getClientErrorData(error);
|
||||||
const { title, description } = getErrorMessages(errorData.type, t);
|
const { title, description } = getErrorMessages(errorData.type, t);
|
||||||
|
|
||||||
useEffect(() => {
|
if (process.env.NODE_ENV === "development") {
|
||||||
if (process.env.NODE_ENV === "development") {
|
console.error(error.message);
|
||||||
console.error(error.message);
|
} else {
|
||||||
} else if (!isExpectedError(error)) {
|
Sentry.captureException(error);
|
||||||
Sentry.captureException(error);
|
}
|
||||||
}
|
|
||||||
}, [error]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ vi.mock("@/app/middleware/endpoint-validator", async () => {
|
|||||||
isClientSideApiRoute: vi.fn().mockReturnValue({ isClientSideApi: false, isRateLimited: true }),
|
isClientSideApiRoute: vi.fn().mockReturnValue({ isClientSideApi: false, isRateLimited: true }),
|
||||||
isManagementApiRoute: vi.fn().mockReturnValue({ isManagementApi: false, authenticationMethod: "apiKey" }),
|
isManagementApiRoute: vi.fn().mockReturnValue({ isManagementApi: false, authenticationMethod: "apiKey" }),
|
||||||
isIntegrationRoute: vi.fn().mockReturnValue(false),
|
isIntegrationRoute: vi.fn().mockReturnValue(false),
|
||||||
|
isSyncWithUserIdentificationEndpoint: vi.fn().mockReturnValue(null),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
|||||||
api: {
|
api: {
|
||||||
client: { windowMs: 60000, max: 100 },
|
client: { windowMs: 60000, max: 100 },
|
||||||
v1: { windowMs: 60000, max: 1000 },
|
v1: { windowMs: 60000, max: 1000 },
|
||||||
|
syncUserIdentification: { windowMs: 60000, max: 50 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -461,6 +463,45 @@ describe("withV1ApiWrapper", () => {
|
|||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("handles sync user identification rate limiting", async () => {
|
||||||
|
const { applyRateLimit, applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||||
|
const {
|
||||||
|
isClientSideApiRoute,
|
||||||
|
isManagementApiRoute,
|
||||||
|
isIntegrationRoute,
|
||||||
|
isSyncWithUserIdentificationEndpoint,
|
||||||
|
} = await import("@/app/middleware/endpoint-validator");
|
||||||
|
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||||
|
|
||||||
|
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
|
||||||
|
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||||
|
isManagementApi: false,
|
||||||
|
authenticationMethod: AuthenticationMethod.None,
|
||||||
|
});
|
||||||
|
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||||
|
vi.mocked(isSyncWithUserIdentificationEndpoint).mockReturnValue({
|
||||||
|
userId: "user-123",
|
||||||
|
environmentId: "env-123",
|
||||||
|
});
|
||||||
|
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||||
|
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||||
|
const rateLimitError = new Error("Sync rate limit exceeded");
|
||||||
|
rateLimitError.message = "Sync rate limit exceeded";
|
||||||
|
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
const req = createMockRequest({ url: "/api/v1/client/env-123/app/sync/user-123" });
|
||||||
|
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||||
|
const wrapped = withV1ApiWrapper({ handler });
|
||||||
|
const res = await wrapped(req, undefined);
|
||||||
|
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
expect(applyRateLimit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ windowMs: 60000, max: 50 }),
|
||||||
|
"user-123"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("skips audit log creation when no action/targetType provided", async () => {
|
test("skips audit log creation when no action/targetType provided", async () => {
|
||||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||||
"@/modules/ee/audit-logs/lib/handler"
|
"@/modules/ee/audit-logs/lib/handler"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
isClientSideApiRoute,
|
isClientSideApiRoute,
|
||||||
isIntegrationRoute,
|
isIntegrationRoute,
|
||||||
isManagementApiRoute,
|
isManagementApiRoute,
|
||||||
|
isSyncWithUserIdentificationEndpoint,
|
||||||
} from "@/app/middleware/endpoint-validator";
|
} from "@/app/middleware/endpoint-validator";
|
||||||
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
@@ -47,16 +48,23 @@ enum ApiV1RouteTypeEnum {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply client-side API rate limiting (IP-based)
|
* Apply client-side API rate limiting (IP-based or sync-specific)
|
||||||
*/
|
*/
|
||||||
const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
|
const applyClientRateLimit = async (url: string, customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
|
||||||
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
|
const syncEndpoint = isSyncWithUserIdentificationEndpoint(url);
|
||||||
|
if (syncEndpoint) {
|
||||||
|
const syncRateLimitConfig = rateLimitConfigs.api.syncUserIdentification;
|
||||||
|
await applyRateLimit(syncRateLimitConfig, syncEndpoint.userId);
|
||||||
|
} else {
|
||||||
|
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle rate limiting based on authentication and API type
|
* Handle rate limiting based on authentication and API type
|
||||||
*/
|
*/
|
||||||
const handleRateLimiting = async (
|
const handleRateLimiting = async (
|
||||||
|
url: string,
|
||||||
authentication: TApiV1Authentication,
|
authentication: TApiV1Authentication,
|
||||||
routeType: ApiV1RouteTypeEnum,
|
routeType: ApiV1RouteTypeEnum,
|
||||||
customRateLimitConfig?: TRateLimitConfig
|
customRateLimitConfig?: TRateLimitConfig
|
||||||
@@ -76,7 +84,7 @@ const handleRateLimiting = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (routeType === ApiV1RouteTypeEnum.Client) {
|
if (routeType === ApiV1RouteTypeEnum.Client) {
|
||||||
await applyClientRateLimit(customRateLimitConfig);
|
await applyClientRateLimit(url, customRateLimitConfig);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return responses.tooManyRequestsResponse(error.message);
|
return responses.tooManyRequestsResponse(error.message);
|
||||||
@@ -247,6 +255,7 @@ const getRouteType = (
|
|||||||
* Features:
|
* Features:
|
||||||
* - Performs authentication once and passes result to handler
|
* - Performs authentication once and passes result to handler
|
||||||
* - Applies API key-based rate limiting with differentiated limits for client vs management APIs
|
* - Applies API key-based rate limiting with differentiated limits for client vs management APIs
|
||||||
|
* - Includes additional sync user identification rate limiting for client-side sync endpoints
|
||||||
* - Sets userId and organizationId in audit log automatically when audit logging is enabled
|
* - Sets userId and organizationId in audit log automatically when audit logging is enabled
|
||||||
* - System and Sentry logs are always called for non-success responses
|
* - System and Sentry logs are always called for non-success responses
|
||||||
* - Uses function overloads to provide type safety without requiring type guards
|
* - Uses function overloads to provide type safety without requiring type guards
|
||||||
@@ -319,7 +328,12 @@ export const withV1ApiWrapper: {
|
|||||||
|
|
||||||
// === Rate Limiting ===
|
// === Rate Limiting ===
|
||||||
if (isRateLimited) {
|
if (isRateLimited) {
|
||||||
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
|
const rateLimitResponse = await handleRateLimiting(
|
||||||
|
req.nextUrl.pathname,
|
||||||
|
authentication,
|
||||||
|
routeType,
|
||||||
|
customRateLimitConfig
|
||||||
|
);
|
||||||
if (rateLimitResponse) return rateLimitResponse;
|
if (rateLimitResponse) return rateLimitResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4848,14 +4848,12 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
|||||||
t("templates.preview_survey_question_2_choice_2_label"),
|
t("templates.preview_survey_question_2_choice_2_label"),
|
||||||
],
|
],
|
||||||
headline: t("templates.preview_survey_question_2_headline"),
|
headline: t("templates.preview_survey_question_2_headline"),
|
||||||
subheader: t("templates.preview_survey_question_2_subheader"),
|
|
||||||
required: true,
|
required: true,
|
||||||
shuffleOption: "none",
|
shuffleOption: "none",
|
||||||
}),
|
}),
|
||||||
isDraft: true,
|
isDraft: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
buttonLabel: createI18nString(t("templates.next"), []),
|
|
||||||
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
|
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
isManagementApiRoute,
|
isManagementApiRoute,
|
||||||
isPublicDomainRoute,
|
isPublicDomainRoute,
|
||||||
isRouteAllowedForDomain,
|
isRouteAllowedForDomain,
|
||||||
|
isSyncWithUserIdentificationEndpoint,
|
||||||
} from "./endpoint-validator";
|
} from "./endpoint-validator";
|
||||||
|
|
||||||
describe("endpoint-validator", () => {
|
describe("endpoint-validator", () => {
|
||||||
@@ -269,6 +270,58 @@ describe("endpoint-validator", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isSyncWithUserIdentificationEndpoint", () => {
|
||||||
|
test("should return environmentId and userId for valid sync URLs", () => {
|
||||||
|
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
|
||||||
|
expect(result1).toEqual({
|
||||||
|
environmentId: "env123",
|
||||||
|
userId: "user456",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/abc-123/app/sync/xyz-789");
|
||||||
|
expect(result2).toEqual({
|
||||||
|
environmentId: "abc-123",
|
||||||
|
userId: "xyz-789",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result3 = isSyncWithUserIdentificationEndpoint(
|
||||||
|
"/api/v1/client/env_123_test/app/sync/user_456_test"
|
||||||
|
);
|
||||||
|
expect(result3).toEqual({
|
||||||
|
environmentId: "env_123_test",
|
||||||
|
userId: "user_456_test",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle optional trailing slash", () => {
|
||||||
|
// Test both with and without trailing slash
|
||||||
|
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
|
||||||
|
expect(result1).toEqual({
|
||||||
|
environmentId: "env123",
|
||||||
|
userId: "user456",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456/");
|
||||||
|
expect(result2).toEqual({
|
||||||
|
environmentId: "env123",
|
||||||
|
userId: "user456",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return false for invalid sync URLs", () => {
|
||||||
|
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync")).toBe(false);
|
||||||
|
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/something")).toBe(false);
|
||||||
|
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
|
||||||
|
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/other/user456")).toBe(false);
|
||||||
|
expect(isSyncWithUserIdentificationEndpoint("/api/v2/client/env123/app/sync/user456")).toBe(false); // only v1 supported
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle empty or malformed IDs", () => {
|
||||||
|
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client//app/sync/user456")).toBe(false);
|
||||||
|
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("isPublicDomainRoute", () => {
|
describe("isPublicDomainRoute", () => {
|
||||||
test("should return true for health endpoint", () => {
|
test("should return true for health endpoint", () => {
|
||||||
expect(isPublicDomainRoute("/health")).toBe(true);
|
expect(isPublicDomainRoute("/health")).toBe(true);
|
||||||
@@ -529,6 +582,12 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle special characters in survey IDs", () => {
|
test("should handle special characters in survey IDs", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
|
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
|
||||||
|
expect(
|
||||||
|
isSyncWithUserIdentificationEndpoint("/api/v1/client/env-123_test/app/sync/user-456_test")
|
||||||
|
).toEqual({
|
||||||
|
environmentId: "env-123_test",
|
||||||
|
userId: "user-456_test",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -569,6 +628,15 @@ describe("endpoint-validator", () => {
|
|||||||
const longSurveyId = "a".repeat(1000);
|
const longSurveyId = "a".repeat(1000);
|
||||||
const longPath = `s/${longSurveyId}`;
|
const longPath = `s/${longSurveyId}`;
|
||||||
expect(isPublicDomainRoute(`/${longPath}`)).toBe(true);
|
expect(isPublicDomainRoute(`/${longPath}`)).toBe(true);
|
||||||
|
|
||||||
|
const longEnvironmentId = "env" + "a".repeat(1000);
|
||||||
|
const longUserId = "user" + "b".repeat(1000);
|
||||||
|
expect(
|
||||||
|
isSyncWithUserIdentificationEndpoint(`/api/v1/client/${longEnvironmentId}/app/sync/${longUserId}`)
|
||||||
|
).toEqual({
|
||||||
|
environmentId: longEnvironmentId,
|
||||||
|
userId: longUserId,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle empty and minimal inputs", () => {
|
test("should handle empty and minimal inputs", () => {
|
||||||
@@ -583,6 +651,7 @@ describe("endpoint-validator", () => {
|
|||||||
});
|
});
|
||||||
expect(isIntegrationRoute("")).toBe(false);
|
expect(isIntegrationRoute("")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("")).toBe(false);
|
expect(isAuthProtectedRoute("")).toBe(false);
|
||||||
|
expect(isSyncWithUserIdentificationEndpoint("")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export const isAuthProtectedRoute = (url: string): boolean => {
|
|||||||
return protectedRoutes.some((route) => url.startsWith(route));
|
return protectedRoutes.some((route) => url.startsWith(route));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isSyncWithUserIdentificationEndpoint = (
|
||||||
|
url: string
|
||||||
|
): { environmentId: string; userId: string } | false => {
|
||||||
|
const regex = /\/api\/v1\/client\/(?<environmentId>[^/]+)\/app\/sync\/(?<userId>[^/]+)/;
|
||||||
|
const match = url.match(regex);
|
||||||
|
return match ? { environmentId: match.groups!.environmentId, userId: match.groups!.userId } : false;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the route should be accessible on the public domain (PUBLIC_URL)
|
* Check if the route should be accessible on the public domain (PUBLIC_URL)
|
||||||
* Uses whitelist approach - only explicitly allowed routes are accessible
|
* Uses whitelist approach - only explicitly allowed routes are accessible
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { authorizePrivateDownload } from "@/app/storage/[environmentId]/[accessT
|
|||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||||
import { deleteFile, getFileStreamForDownload } from "@/modules/storage/service";
|
import { deleteFile, getSignedUrlForDownload } from "@/modules/storage/service";
|
||||||
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
|
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
|
||||||
import { logFileDeletion } from "./lib/audit-logs";
|
import { logFileDeletion } from "./lib/audit-logs";
|
||||||
|
|
||||||
@@ -39,25 +39,21 @@ export const GET = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream the file directly
|
const signedUrlResult = await getSignedUrlForDownload(fileName, environmentId, accessType);
|
||||||
const streamResult = await getFileStreamForDownload(fileName, environmentId, accessType);
|
|
||||||
|
|
||||||
if (!streamResult.ok) {
|
if (!signedUrlResult.ok) {
|
||||||
const errorResponse = getErrorResponseFromStorageError(streamResult.error, { fileName });
|
const errorResponse = getErrorResponseFromStorageError(signedUrlResult.error, { fileName });
|
||||||
return errorResponse;
|
return errorResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { body, contentType, contentLength } = streamResult.data;
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
return new Response(body, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
Location: signedUrlResult.data,
|
||||||
...(contentLength > 0 && { "Content-Length": String(contentLength) }),
|
|
||||||
"Cache-Control":
|
"Cache-Control":
|
||||||
accessType === "private"
|
accessType === "private"
|
||||||
? "no-store, no-cache, must-revalidate"
|
? "no-store, no-cache, must-revalidate"
|
||||||
: "public, max-age=31536000, immutable",
|
: "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
+5
-6
@@ -9,18 +9,17 @@
|
|||||||
"source": "en-US",
|
"source": "en-US",
|
||||||
"targets": [
|
"targets": [
|
||||||
"de-DE",
|
"de-DE",
|
||||||
"es-ES",
|
|
||||||
"fr-FR",
|
"fr-FR",
|
||||||
"hu-HU",
|
|
||||||
"ja-JP",
|
"ja-JP",
|
||||||
"nl-NL",
|
|
||||||
"pt-BR",
|
"pt-BR",
|
||||||
"pt-PT",
|
"pt-PT",
|
||||||
"ro-RO",
|
"ro-RO",
|
||||||
"ru-RU",
|
|
||||||
"sv-SE",
|
|
||||||
"zh-Hans-CN",
|
"zh-Hans-CN",
|
||||||
"zh-Hant-TW"
|
"zh-Hant-TW",
|
||||||
|
"nl-NL",
|
||||||
|
"es-ES",
|
||||||
|
"sv-SE",
|
||||||
|
"ru-RU"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"version": 1.8
|
"version": 1.8
|
||||||
|
|||||||
+234
-380
File diff suppressed because it is too large
Load Diff
@@ -1,205 +1,59 @@
|
|||||||
// OpenTelemetry instrumentation for Next.js - loaded via instrumentation.ts hook
|
// instrumentation-node.ts
|
||||||
// Pattern based on: ee/src/opentelemetry.ts (license server)
|
|
||||||
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
|
||||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
|
||||||
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
|
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
|
||||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
import { HostMetrics } from "@opentelemetry/host-metrics";
|
||||||
import { resourceFromAttributes } from "@opentelemetry/resources";
|
import { registerInstrumentations } from "@opentelemetry/instrumentation";
|
||||||
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
|
||||||
import { NodeSDK } from "@opentelemetry/sdk-node";
|
import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node";
|
||||||
import {
|
import {
|
||||||
AlwaysOffSampler,
|
detectResources,
|
||||||
AlwaysOnSampler,
|
envDetector,
|
||||||
BatchSpanProcessor,
|
hostDetector,
|
||||||
ParentBasedSampler,
|
processDetector,
|
||||||
type Sampler,
|
resourceFromAttributes,
|
||||||
TraceIdRatioBasedSampler,
|
} from "@opentelemetry/resources";
|
||||||
} from "@opentelemetry/sdk-trace-base";
|
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
|
|
||||||
import { PrismaInstrumentation } from "@prisma/instrumentation";
|
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
|
||||||
// --- Configuration from environment ---
|
const exporter = new PrometheusExporter({
|
||||||
const serviceName = process.env.OTEL_SERVICE_NAME || "formbricks";
|
port: env.PROMETHEUS_EXPORTER_PORT ? parseInt(env.PROMETHEUS_EXPORTER_PORT) : 9464,
|
||||||
const serviceVersion = process.env.npm_package_version || "0.0.0";
|
endpoint: "/metrics",
|
||||||
const environment = process.env.ENVIRONMENT || process.env.NODE_ENV || "development";
|
host: "0.0.0.0", // Listen on all network interfaces
|
||||||
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
||||||
const prometheusEnabled = process.env.PROMETHEUS_ENABLED === "1";
|
|
||||||
const prometheusPort = process.env.PROMETHEUS_EXPORTER_PORT
|
|
||||||
? Number.parseInt(process.env.PROMETHEUS_EXPORTER_PORT)
|
|
||||||
: 9464;
|
|
||||||
|
|
||||||
// --- Configure OTLP exporters (conditional on endpoint being set) ---
|
|
||||||
let traceExporter: OTLPTraceExporter | undefined;
|
|
||||||
let otlpMetricExporter: OTLPMetricExporter | undefined;
|
|
||||||
|
|
||||||
if (otlpEndpoint) {
|
|
||||||
try {
|
|
||||||
// OTLPTraceExporter reads OTEL_EXPORTER_OTLP_ENDPOINT from env
|
|
||||||
// and appends /v1/traces for HTTP transport
|
|
||||||
// Uses OTEL_EXPORTER_OTLP_HEADERS from env natively (W3C OTel format: key=value,key2=value2)
|
|
||||||
traceExporter = new OTLPTraceExporter();
|
|
||||||
|
|
||||||
// OTLPMetricExporter reads OTEL_EXPORTER_OTLP_ENDPOINT from env
|
|
||||||
// and appends /v1/metrics for HTTP transport
|
|
||||||
// Uses OTEL_EXPORTER_OTLP_HEADERS from env natively
|
|
||||||
otlpMetricExporter = new OTLPMetricExporter();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error, "Failed to create OTLP exporters. Telemetry will not be exported.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Configure Prometheus exporter (pull-based metrics for ServiceMonitor) ---
|
|
||||||
let prometheusExporter: PrometheusExporter | undefined;
|
|
||||||
if (prometheusEnabled) {
|
|
||||||
prometheusExporter = new PrometheusExporter({
|
|
||||||
port: prometheusPort,
|
|
||||||
endpoint: "/metrics",
|
|
||||||
host: "0.0.0.0",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Build metric readers array ---
|
|
||||||
const metricReaders: (PeriodicExportingMetricReader | PrometheusExporter)[] = [];
|
|
||||||
|
|
||||||
if (otlpMetricExporter) {
|
|
||||||
metricReaders.push(
|
|
||||||
new PeriodicExportingMetricReader({
|
|
||||||
exporter: otlpMetricExporter,
|
|
||||||
exportIntervalMillis: 60000, // Export every 60 seconds
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prometheusExporter) {
|
|
||||||
metricReaders.push(prometheusExporter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Resource attributes ---
|
|
||||||
const resourceAttributes: Record<string, string> = {
|
|
||||||
[ATTR_SERVICE_NAME]: serviceName,
|
|
||||||
[ATTR_SERVICE_VERSION]: serviceVersion,
|
|
||||||
"deployment.environment": environment,
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Configure sampler ---
|
|
||||||
const samplerType = process.env.OTEL_TRACES_SAMPLER || "always_on";
|
|
||||||
const parsedSamplerArg = process.env.OTEL_TRACES_SAMPLER_ARG
|
|
||||||
? Number.parseFloat(process.env.OTEL_TRACES_SAMPLER_ARG)
|
|
||||||
: undefined;
|
|
||||||
const samplerArg =
|
|
||||||
parsedSamplerArg !== undefined && !Number.isNaN(parsedSamplerArg) ? parsedSamplerArg : undefined;
|
|
||||||
|
|
||||||
let sampler: Sampler;
|
|
||||||
switch (samplerType) {
|
|
||||||
case "always_on":
|
|
||||||
sampler = new AlwaysOnSampler();
|
|
||||||
break;
|
|
||||||
case "always_off":
|
|
||||||
sampler = new AlwaysOffSampler();
|
|
||||||
break;
|
|
||||||
case "traceidratio":
|
|
||||||
sampler = new TraceIdRatioBasedSampler(samplerArg ?? 1);
|
|
||||||
break;
|
|
||||||
case "parentbased_traceidratio":
|
|
||||||
sampler = new ParentBasedSampler({
|
|
||||||
root: new TraceIdRatioBasedSampler(samplerArg ?? 1),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case "parentbased_always_on":
|
|
||||||
sampler = new ParentBasedSampler({
|
|
||||||
root: new AlwaysOnSampler(),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case "parentbased_always_off":
|
|
||||||
sampler = new ParentBasedSampler({
|
|
||||||
root: new AlwaysOffSampler(),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
logger.warn(`Unknown sampler type: ${samplerType}. Using always_on.`);
|
|
||||||
sampler = new AlwaysOnSampler();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Initialize NodeSDK ---
|
|
||||||
const sdk = new NodeSDK({
|
|
||||||
sampler,
|
|
||||||
resource: resourceFromAttributes(resourceAttributes),
|
|
||||||
// When no OTLP endpoint is configured (e.g. Prometheus-only setups), pass an empty
|
|
||||||
// spanProcessors array to prevent the SDK from falling back to its default OTLP exporter
|
|
||||||
// which would attempt connections to localhost:4318 and cause noisy errors.
|
|
||||||
spanProcessors: traceExporter
|
|
||||||
? [
|
|
||||||
new BatchSpanProcessor(traceExporter, {
|
|
||||||
maxQueueSize: 2048,
|
|
||||||
maxExportBatchSize: 512,
|
|
||||||
scheduledDelayMillis: 5000,
|
|
||||||
exportTimeoutMillis: 30000,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
metricReaders: metricReaders.length > 0 ? metricReaders : undefined,
|
|
||||||
instrumentations: [
|
|
||||||
getNodeAutoInstrumentations({
|
|
||||||
// Disable noisy/unnecessary instrumentations
|
|
||||||
"@opentelemetry/instrumentation-fs": {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
"@opentelemetry/instrumentation-dns": {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
"@opentelemetry/instrumentation-net": {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
// Disable pg instrumentation - PrismaInstrumentation handles DB tracing
|
|
||||||
"@opentelemetry/instrumentation-pg": {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
"@opentelemetry/instrumentation-http": {
|
|
||||||
// Ignore health/metrics endpoints to reduce noise
|
|
||||||
ignoreIncomingRequestHook: (req) => {
|
|
||||||
const url = req.url || "";
|
|
||||||
return url === "/health" || url.startsWith("/metrics") || url === "/api/v2/health";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Enable runtime metrics for Node.js process monitoring
|
|
||||||
"@opentelemetry/instrumentation-runtime-node": {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
// Prisma instrumentation for database query tracing
|
|
||||||
new PrismaInstrumentation(),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the SDK
|
const detectedResources = detectResources({
|
||||||
sdk.start();
|
detectors: [envDetector, processDetector, hostDetector],
|
||||||
|
});
|
||||||
|
|
||||||
// --- Log initialization status ---
|
const customResources = resourceFromAttributes({});
|
||||||
const enabledFeatures: string[] = [];
|
|
||||||
if (traceExporter) enabledFeatures.push("traces");
|
|
||||||
if (otlpMetricExporter) enabledFeatures.push("otlp-metrics");
|
|
||||||
if (prometheusExporter) enabledFeatures.push("prometheus-metrics");
|
|
||||||
|
|
||||||
const samplerArgStr = process.env.OTEL_TRACES_SAMPLER_ARG || "";
|
const resources = detectedResources.merge(customResources);
|
||||||
const samplerArgMsg = samplerArgStr ? `, samplerArg=${samplerArgStr}` : "";
|
|
||||||
|
|
||||||
if (enabledFeatures.length > 0) {
|
const meterProvider = new MeterProvider({
|
||||||
logger.info(
|
readers: [exporter],
|
||||||
`OpenTelemetry initialized: service=${serviceName}, version=${serviceVersion}, environment=${environment}, exporters=${enabledFeatures.join("+")}, sampler=${samplerType}${samplerArgMsg}`
|
resource: resources,
|
||||||
);
|
});
|
||||||
} else {
|
|
||||||
logger.info(
|
|
||||||
`OpenTelemetry initialized (no exporters): service=${serviceName}, version=${serviceVersion}, environment=${environment}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Graceful shutdown ---
|
const hostMetrics = new HostMetrics({
|
||||||
// Run before other SIGTERM listeners (logger flush, etc.) so spans are drained first.
|
name: `otel-metrics`,
|
||||||
process.prependListener("SIGTERM", async () => {
|
meterProvider,
|
||||||
|
});
|
||||||
|
|
||||||
|
registerInstrumentations({
|
||||||
|
meterProvider,
|
||||||
|
instrumentations: [new HttpInstrumentation(), new RuntimeNodeInstrumentation()],
|
||||||
|
});
|
||||||
|
|
||||||
|
hostMetrics.start();
|
||||||
|
|
||||||
|
process.on("SIGTERM", async () => {
|
||||||
try {
|
try {
|
||||||
await sdk.shutdown();
|
// Stop collecting metrics or flush them if needed
|
||||||
|
await meterProvider.shutdown();
|
||||||
|
// Possibly close other instrumentation resources
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e, "Error during OpenTelemetry shutdown");
|
logger.error(e, "Error during graceful shutdown");
|
||||||
|
} finally {
|
||||||
|
process.exit(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,13 +5,10 @@ export const onRequestError = Sentry.captureRequestError;
|
|||||||
|
|
||||||
export const register = async () => {
|
export const register = async () => {
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
// Load OpenTelemetry instrumentation when Prometheus metrics or OTLP export is enabled
|
if (PROMETHEUS_ENABLED) {
|
||||||
if (PROMETHEUS_ENABLED || process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
|
||||||
await import("./instrumentation-node");
|
await import("./instrumentation-node");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sentry init loads after OTEL to avoid TracerProvider conflicts
|
|
||||||
// Sentry tracing is disabled (tracesSampleRate: 0) -- SigNoz handles distributed tracing
|
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs" && IS_PRODUCTION && SENTRY_DSN) {
|
if (process.env.NEXT_RUNTIME === "nodejs" && IS_PRODUCTION && SENTRY_DSN) {
|
||||||
await import("./sentry.server.config");
|
await import("./sentry.server.config");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,20 +165,19 @@ export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
|
|||||||
|
|
||||||
export const DEFAULT_LOCALE = "en-US";
|
export const DEFAULT_LOCALE = "en-US";
|
||||||
export const AVAILABLE_LOCALES: TUserLocale[] = [
|
export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||||
"de-DE",
|
|
||||||
"en-US",
|
"en-US",
|
||||||
"es-ES",
|
"de-DE",
|
||||||
"fr-FR",
|
|
||||||
"hu-HU",
|
|
||||||
"ja-JP",
|
|
||||||
"nl-NL",
|
|
||||||
"pt-BR",
|
"pt-BR",
|
||||||
|
"fr-FR",
|
||||||
|
"nl-NL",
|
||||||
|
"zh-Hant-TW",
|
||||||
"pt-PT",
|
"pt-PT",
|
||||||
"ro-RO",
|
"ro-RO",
|
||||||
"ru-RU",
|
"ja-JP",
|
||||||
"sv-SE",
|
|
||||||
"zh-Hans-CN",
|
"zh-Hans-CN",
|
||||||
"zh-Hant-TW",
|
"es-ES",
|
||||||
|
"sv-SE",
|
||||||
|
"ru-RU",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Billing constants
|
// Billing constants
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const env = createEnv({
|
|||||||
OIDC_DISPLAY_NAME: z.string().optional(),
|
OIDC_DISPLAY_NAME: z.string().optional(),
|
||||||
OIDC_ISSUER: z.string().optional(),
|
OIDC_ISSUER: z.string().optional(),
|
||||||
OIDC_SIGNING_ALGORITHM: z.string().optional(),
|
OIDC_SIGNING_ALGORITHM: z.string().optional(),
|
||||||
|
OPENTELEMETRY_LISTENER_URL: z.string().optional(),
|
||||||
REDIS_URL:
|
REDIS_URL:
|
||||||
process.env.NODE_ENV === "test"
|
process.env.NODE_ENV === "test"
|
||||||
? z.string().optional()
|
? z.string().optional()
|
||||||
@@ -173,6 +174,7 @@ export const env = createEnv({
|
|||||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||||
|
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
|
||||||
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
|
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
|
||||||
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
|
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
|
||||||
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,
|
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,
|
||||||
|
|||||||
@@ -167,12 +167,6 @@ export const createEnvironment = async (
|
|||||||
description: "Your contact's last name",
|
description: "Your contact's last name",
|
||||||
type: "default",
|
type: "default",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "language",
|
|
||||||
name: "Language",
|
|
||||||
description: "The language preference of a contact",
|
|
||||||
type: "default",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user