Compare commits

..

49 Commits

Author SHA1 Message Date
Theodór Tómas
4916a707d5 Merge branch 'main' into feat/css-variables 2026-02-11 00:18:36 +07:00
TheodorTomas
444632e83d fix: default button size, margin, padding 2026-02-10 19:18:58 +07:00
TheodorTomas
1fcdad6b76 fix: default preview brand colors 2026-02-10 16:29:47 +07:00
TheodorTomas
c926ce4a9d fix: onboarding preview brand color 2026-02-10 15:02:58 +07:00
Dhruwang
250daa752d Merge branch 'main' of https://github.com/formbricks/formbricks into feat/css-variables 2026-02-10 10:45:49 +05:30
TheodorTomas
30caf5d704 fixing e2e test 2026-02-09 22:21:45 +07:00
TheodorTomas
08c00daf36 Fixing issues after review 2026-02-09 21:48:11 +07:00
TheodorTomas
76be9b3470 fix: use standard secondary variant for Suggest colors button
Remove custom color/border overrides on the Suggest colors button and
rely on the built-in secondary Button variant instead.
2026-02-09 14:00:16 +07:00
TheodorTomas
c9bae02ca1 fix: use consistent fixed height for theme styling preview
Use a single 660px fixed height for the theme styling preview container
for both app and link survey types, replacing the previous conditional
height that left the link survey preview too short.
2026-02-09 13:39:38 +07:00
Johannes
9ceb490b7d Merge branch 'main' of https://github.com/formbricks/formbricks into feat/css-variables 2026-02-06 15:18:36 -03:00
TheodorTomas
b3ac2c70de fix: allow overflow in expanded StylingSection so color picker is not clipped 2026-02-06 21:03:26 +08:00
TheodorTomas
6c7aa64d2e fix: revert CSS defaults to static values and align ADVANCED_DEFAULTS with main branch
- Revert globals.css to pre-bug static defaults (no var(--fb-survey-brand-color)
  live bindings) so changing the brand color picker doesn't cascade to other
  elements
- Align ADVANCED_DEFAULTS with old main branch visual defaults (button px-3
  py-3 text-base font-medium rounded-custom, headline font-semibold, description
  slate-700, input border radius 8, etc.)
- Add missing color fields to getBrandDerivedDefaults (questionColor,
  inputColor, inputBorderColor, cardBackgroundColor, cardBorderColor) so
  first-time users with no saved styles get a fully brand-derived theme
2026-02-06 20:53:59 +08:00
TheodorTomas
0e03096014 feat: add headline to preview survey input field
The open-text input in the workspace look preview was missing a
headline label. Add "Anything else to share?" so the field is
consistent with the other question elements in the preview.
2026-02-06 18:42:27 +08:00
TheodorTomas
a422b7acc3 fix: show browser chrome header for app survey preview in theme settings
The header bar (red/amber/green dots, "Your web app" label, reset button)
was hidden for app survey previews due to a `!isAppSurvey` guard, causing
the top of the preview to appear cut off.
2026-02-06 18:26:01 +08:00
TheodorTomas
8f02655925 fix: derive styling colors from brand color and add description font weight
- Adjust suggest colors mix ratios to match original behavior (inputs at
  92% white, input borders at 60% white, question color at 35% black)
- Derive button, progress, input, option, headline, and accent colors
  from the saved brand color on initial load via getBrandDerivedDefaults
- Update CSS defaults in globals.css to reference --fb-survey-brand-color
- Add elementDescriptionFontWeight field to styling type, form, and
  addCustomThemeToDom
- Add e2e test for initial load brand color derivation
2026-02-06 17:43:57 +08:00
TheodorTomas
d31caf37ab fix: prevent StylingSection background from overflowing rounded border 2026-02-06 17:31:35 +08:00
TheodorTomas
68dee72531 feat: add brand color picker and suggest colors to survey editor styling tab
Extract shared getSuggestedColors() to keep color derivation DRY between
the project-level theme page and the per-survey styling editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 17:26:16 +08:00
TheodorTomas
f07225c953 chore: remove unused tailwind utility extensions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 16:17:42 +08:00
TheodorTomas
495d0eb338 fix: derive suggest colors from brand color instead of hardcoding
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 16:17:39 +08:00
TheodorTomas
5c6f1e998e refactor: extract styling field components into separate files
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 16:17:35 +08:00
TheodorTomas
7563793643 Merge remote-tracking branch 'origin/main' into feat/css-variables 2026-02-06 15:05:48 +08:00
TheodorTomas
93b7dfa3fa Merge remote-tracking branch 'origin/main' into feat/css-variables 2026-02-06 11:57:53 +08:00
TheodorTomas
290a53eb8e fix: removing unneeded changes 2026-02-05 14:21:27 +08:00
TheodorTomas
afcc8069ed chore: bump node version to 20 in translation workflow 2026-02-05 03:11:25 +08:00
TheodorTomas
0b8bef5861 chore: regenerate i18n lockfiles and translations from main 2026-02-05 02:46:06 +08:00
TheodorTomas
42f47419eb fix: generating locale files again 2026-02-05 00:44:32 +08:00
TheodorTomas
62d0109837 chore: reset i18n files to match main 2026-02-05 00:33:41 +08:00
TheodorTomas
704e925d19 Merge main and resolve pnpm-lock.yaml conflict 2026-02-05 00:03:15 +08:00
TheodorTomas
5b7b3458c5 chore: update pnpm-lock.yaml to match package.json 2026-02-04 23:58:40 +08:00
TheodorTomas
cfbd67d4c4 fix: force CI to correct 2026-02-04 23:51:16 +08:00
TheodorTomas
d389037ae9 chore: removing unneeded code after self review 2026-02-04 23:39:29 +08:00
TheodorTomas
7a6ac93a7f fix: small fix 2026-02-04 23:23:34 +08:00
TheodorTomas
d7c6d465fc fix: fixing broken e2e tests 2026-02-04 23:16:22 +08:00
TheodorTomas
493aeeb1f1 fix: fixing e2e tests 2026-02-04 22:25:35 +08:00
TheodorTomas
b947b70321 fix: fixing broken HU translations after merge 2026-02-04 20:44:53 +08:00
TheodorTomas
a289af7c5d Merge remote-tracking branch 'origin/main' into feat/css-variables 2026-02-04 18:54:11 +08:00
TheodorTomas
dc9251950c feat: a couple small improvements 2026-02-04 14:15:35 +08:00
TheodorTomas
9fbe32c6ab feat: fixing the font weight not appearing in the preview 2026-02-04 13:58:46 +08:00
TheodorTomas
639d63be5e feat: removing extra comment 2026-02-04 11:45:54 +08:00
TheodorTomas
f0b3d8638b feat: renaming label-upper-label to label-upper 2026-02-04 11:38:44 +08:00
TheodorTomas
ac838e0710 feat: fixing issue from PR review 2026-02-04 11:28:28 +08:00
TheodorTomas
45fc508f5b chore: fixing rabbitai issues 2026-02-02 21:25:55 +08:00
TheodorTomas
726d4b67f9 chore: adding test coverage to styles.ts 2026-02-02 21:25:55 +08:00
TheodorTomas
2fc7827f8e chore: adding more styles.test.ts unit tests 2026-02-02 21:25:55 +08:00
TheodorTomas
a1364995d1 chore: fix unit test 2026-02-02 21:25:55 +08:00
TheodorTomas
684e0c54c7 chore: cleanup after self review 2026-02-02 21:25:55 +08:00
TheodorTomas
39851de1b9 chore: cleanup after self review 2026-02-02 21:25:55 +08:00
TheodorTomas
e5134d5824 chore: cleanup after self review 2026-02-02 21:25:51 +08:00
TheodorTomas
57555d1688 feat: advance css vars 2026-02-02 21:21:45 +08:00
652 changed files with 17377 additions and 27796 deletions

View File

@@ -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=

View File

@@ -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"

View File

@@ -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 }}

View File

@@ -6,9 +6,19 @@ permissions:
on: on:
pull_request: pull_request:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
push: push:
branches: branches:
- main - main
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
jobs: jobs:
validate-translations: validate-translations:
@@ -23,38 +33,30 @@ jobs:
egress-policy: audit egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check for relevant changes
id: changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with: with:
filters: | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
translations:
- 'apps/web/**/*.ts'
- 'apps/web/**/*.tsx'
- 'apps/web/locales/**/*.json'
- 'packages/surveys/src/**/*.{ts,tsx}'
- 'packages/surveys/locales/**/*.json'
- 'packages/email/**/*.{ts,tsx}'
- name: Setup Node.js 22.x - name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with: with:
node-version: 22.x node-version: 22.x
- name: Install pnpm - name: Install pnpm
if: steps.changes.outputs.translations == 'true'
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies - name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64 run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys - name: Validate translation keys
if: steps.changes.outputs.translations == 'true' run: |
run: pnpm run scan-translations echo ""
echo "🔍 Validating translation keys..."
echo ""
pnpm run scan-translations
- name: Skip (no translation-related changes) - name: Summary
if: steps.changes.outputs.translations != 'true' if: success()
run: echo "No translation-related files changed — skipping validation." run: |
echo ""
echo "✅ Translation validation completed successfully!"
echo ""

2
.husky/post-checkout Normal file
View File

@@ -0,0 +1,2 @@
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
prettier --write ./branch.json

View File

@@ -1 +1,40 @@
pnpm lint-staged # Load environment variables from .env files
if [ -f .env ]; then
set -a
. .env
set +a
fi
pnpm lint-staged
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
if [ -n "$LINGODOTDEV_API_KEY" ]; then
echo ""
echo "🌍 Running Lingo.dev translation workflow..."
echo ""
# Run translation generation and validation
if pnpm run i18n; then
echo ""
echo "✅ Translation validation passed"
echo ""
# Add updated locale files to git
git add apps/web/locales/*.json
else
echo ""
echo "❌ Translation validation failed!"
echo ""
echo "Please fix the translation issues above before committing:"
echo " • Add missing translation keys to your locale files"
echo " • Remove unused translation keys"
echo ""
echo "Or run 'pnpm i18n' to see the detailed report"
echo ""
exit 1
fi
else
echo ""
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
echo " (This is expected for community contributors)"
echo ""
fi

View File

@@ -10,20 +10,25 @@
"build-storybook": "storybook build", "build-storybook": "storybook build",
"clean": "rimraf .turbo node_modules dist storybook-static" "clean": "rimraf .turbo node_modules dist storybook-static"
}, },
"dependencies": {
"@formbricks/survey-ui": "workspace:*"
},
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^5.0.1", "@chromatic-com/storybook": "^5.0.0",
"@storybook/addon-a11y": "10.2.15", "@storybook/addon-a11y": "10.1.11",
"@storybook/addon-links": "10.2.15", "@storybook/addon-links": "10.1.11",
"@storybook/addon-onboarding": "10.2.15", "@storybook/addon-onboarding": "10.1.11",
"@storybook/react-vite": "10.2.15", "@storybook/react-vite": "10.1.11",
"@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/eslint-plugin": "8.53.0",
"@tailwindcss/vite": "4.2.1", "@tailwindcss/vite": "4.1.18",
"@typescript-eslint/parser": "8.56.1", "@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.4", "@vitejs/plugin-react": "5.1.2",
"esbuild": "0.25.12",
"eslint-plugin-react-refresh": "0.4.26", "eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.14", "eslint-plugin-storybook": "10.1.11",
"storybook": "10.2.15", "prop-types": "15.8.1",
"storybook": "10.1.11",
"vite": "7.3.1", "vite": "7.3.1",
"@storybook/addon-docs": "10.2.15" "@storybook/addon-docs": "10.1.11"
} }
} }

View File

@@ -1,6 +0,0 @@
const baseConfig = require("../../.prettierrc.js");
module.exports = {
...baseConfig,
tailwindConfig: "./tailwind.config.js",
};

View File

@@ -101,9 +101,6 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
# Create packages/database directory structure with proper ownership for runtime migrations # Create packages/database directory structure with proper ownership for runtime migrations
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
@@ -129,22 +126,6 @@ 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().
# 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 # Install prisma CLI globally for database migrations and fix permissions for nextjs user
RUN npm install --ignore-scripts -g prisma@6 \ RUN npm install --ignore-scripts -g prisma@6 \
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma && chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma

View File

@@ -69,7 +69,7 @@ export const ConnectWithFormbricks = ({
) : ( ) : (
<div className="flex animate-pulse flex-col items-center space-y-4"> <div className="flex animate-pulse flex-col items-center space-y-4">
<span className="relative flex h-10 w-10"> <span className="relative flex h-10 w-10">
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span> <span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span> <span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span> </span>
<p className="pt-4 text-sm font-medium text-slate-600"> <p className="pt-4 text-sm font-medium text-slate-600">

View File

@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel} channel={channel}
/> />
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}`}> <Link href={`/environments/${environment.id}`}>

View File

@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} /> <XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && ( {projects.length >= 2 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}/surveys`}> <Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
return ( return (
<aside <aside
className={cn( className={cn(
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100" "w-sidebar-collapsed z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
)}> )}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} /> <Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />

View File

@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>

View File

@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>

View File

@@ -228,7 +228,7 @@ export const ProjectSettings = ({
</FormProvider> </FormProvider>
</div> </div>
<div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow"> <div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
{logoUrl && ( {logoUrl && (
<Image <Image
src={logoUrl} src={logoUrl}
@@ -239,16 +239,18 @@ export const ProjectSettings = ({
/> />
)} )}
<p className="text-sm text-slate-400">{t("common.preview")}</p> <p className="text-sm text-slate-400">{t("common.preview")}</p>
<SurveyInline <div className="z-0 h-3/4 w-3/4">
appUrl={publicDomain} <SurveyInline
isPreviewMode={true} appUrl={publicDomain}
survey={previewSurvey(projectName || t("common.my_product"), t)} isPreviewMode={true}
styling={previewStyling} survey={previewSurvey(projectName || "my Product", t)}
isBrandingEnabled={false} styling={previewStyling}
languageCode="default" isBrandingEnabled={false}
onFileUpload={async (file) => file.name} languageCode="default"
autoFocus={false} onFileUpload={async (file) => file.name}
/> autoFocus={false}
/>
</div>
</div> </div>
<CreateTeamModal <CreateTeamModal
open={createTeamModalOpen} open={createTeamModalOpen}

View File

@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/> />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>

View File

@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
export const ZOrganizationTeam = z.object({ export const ZOrganizationTeam = z.object({
id: z.cuid2(), id: z.string().cuid2(),
name: z.string(), name: z.string(),
}); });

View File

@@ -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";
@@ -25,7 +25,7 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput, data: ZProjectUpdateInput,
}); });
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action( export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
withAuditLogging( withAuditLogging(
"created", "created",
"project", "project",
@@ -97,7 +97,7 @@ const ZGetOrganizationsForSwitcherAction = z.object({
* Called on-demand when user opens the organization switcher. * Called on-demand when user opens the organization switcher.
*/ */
export const getOrganizationsForSwitcherAction = authenticatedActionClient export const getOrganizationsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetOrganizationsForSwitcherAction) .schema(ZGetOrganizationsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -122,7 +122,7 @@ const ZGetProjectsForSwitcherAction = z.object({
* Called on-demand when user opens the project switcher. * Called on-demand when user opens the project switcher.
*/ */
export const getProjectsForSwitcherAction = authenticatedActionClient export const getProjectsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetProjectsForSwitcherAction) .schema(ZGetProjectsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -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);

View File

@@ -11,7 +11,6 @@ import {
RocketIcon, RocketIcon,
UserCircleIcon, UserCircleIcon,
UserIcon, UserIcon,
WorkflowIcon,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@@ -110,17 +109,7 @@ export const MainNavigation = ({
href: `/environments/${environment.id}/contacts`, href: `/environments/${environment.id}/contacts`,
name: t("common.contacts"), name: t("common.contacts"),
icon: UserIcon, icon: UserIcon,
isActive: isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
},
{
name: t("common.workflows"),
href: `/environments/${environment.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.includes("/workflows"),
isHidden: !isFormbricksCloud,
}, },
{ {
name: t("common.configuration"), name: t("common.configuration"),
@@ -129,7 +118,7 @@ export const MainNavigation = ({
isActive: pathname?.includes("/project"), isActive: pathname?.includes("/project"),
}, },
], ],
[t, environment.id, pathname, isFormbricksCloud] [t, environment.id, pathname]
); );
const dropdownNavigation = [ const dropdownNavigation = [
@@ -196,7 +185,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:outline-none focus:ring-0 focus:ring-transparent" "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"
)}> )}>
{isCollapsed ? ( {isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} /> <PanelLeftOpenIcon strokeWidth={1.5} />

View File

@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon /> <currentStatus.icon />
</div> </div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p> <p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p> <p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
{status === "notImplemented" && ( {status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}> <Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon /> <RotateCcwIcon />

View File

@@ -81,7 +81,7 @@ export const OrganizationBreadcrumb = ({
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => { getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) { if (result?.data) {
// Sort organizations by name // Sort organizations by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name)); const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted); setOrganizations(sorted);
} else { } else {
// Handle server errors or validation errors // Handle server errors or validation errors

View File

@@ -82,7 +82,7 @@ export const ProjectBreadcrumb = ({
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => { getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) { if (result?.data) {
// Sort projects by name // Sort projects by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name)); const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setProjects(sorted); setProjects(sorted);
} else { } else {
// Handle server errors or validation errors // Handle server errors or validation errors

View File

@@ -12,7 +12,7 @@ const ZUpdateNotificationSettingsAction = z.object({
}); });
export const updateNotificationSettingsAction = authenticatedActionClient export const updateNotificationSettingsAction = authenticatedActionClient
.inputSchema(ZUpdateNotificationSettingsAction) .schema(ZUpdateNotificationSettingsAction)
.action( .action(
withAuditLogging( withAuditLogging(
"updated", "updated",

View File

@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
const isChecked = const isChecked =
notificationType === "unsubscribedOrganizationIds" notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId) ? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true; : notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
const handleSwitchChange = async () => { const handleSwitchChange = async () => {
setIsLoading(true); setIsLoading(true);
@@ -49,11 +49,8 @@ export const NotificationSwitch = ({
]; ];
} }
} else { } else {
updatedNotificationSettings[notificationType] = { updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
...updatedNotificationSettings[notificationType], !updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
[surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
};
} }
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({ const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
@@ -81,7 +78,7 @@ export const NotificationSwitch = ({
) { ) {
switch (notificationType) { switch (notificationType) {
case "alert": case "alert":
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) { if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
handleSwitchChange(); handleSwitchChange();
toast.success( toast.success(
t( t(

View File

@@ -63,7 +63,7 @@ async function handleEmailUpdate({
return payload; return payload;
} }
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action( export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging( withAuditLogging(
"updated", "updated",
"user", "user",

View File

@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user"; import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal"; import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils"; import { appLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -198,54 +198,41 @@ export const EditProfileDetailsForm = ({
<FormField <FormField
control={form.control} control={form.control}
name="locale" name="locale"
render={({ field }) => { render={({ field }) => (
const selectedLanguage = appLanguages.find((l) => l.code === field.value); <FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
return ( <FormControl>
<FormItem className="mt-4"> <DropdownMenu>
<FormLabel>{t("common.language")}</FormLabel> <DropdownMenuTrigger asChild>
<FormControl> <Button
<DropdownMenu> type="button"
<DropdownMenuTrigger asChild> variant="ghost"
<Button className="h-10 w-full border border-slate-300 px-3 text-left">
type="button" <div className="flex w-full items-center justify-between">
variant="ghost" {appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
className="h-10 w-full border border-slate-300 px-3 text-left"> <ChevronDownIcon className="h-4 w-4 text-slate-500" />
<div className="flex w-full items-center justify-between"> </div>
{selectedLanguage ? ( </Button>
<> </DropdownMenuTrigger>
{selectedLanguage.label["en-US"]} <DropdownMenuContent
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] && className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
` (${selectedLanguage.label.native})`} align="start">
</> <DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
) : ( {appLanguages.map((lang) => (
t("common.select") <DropdownMenuRadioItem
)} key={lang.code}
<ChevronDownIcon className="h-4 w-4 text-slate-500" /> value={lang.code}
</div> className="min-h-8 cursor-pointer">
</Button> {lang.label["en-US"]}
</DropdownMenuTrigger> </DropdownMenuRadioItem>
<DropdownMenuContent ))}
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700" </DropdownMenuRadioGroup>
align="start"> </DropdownMenuContent>
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}> </DropdownMenu>
{sortedAppLanguages.map((lang) => ( </FormControl>
<DropdownMenuRadioItem <FormError />
key={lang.code} </FormItem>
value={lang.code} )}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
);
}}
/> />
{isPasswordResetEnabled && ( {isPasswordResetEnabled && (

View File

@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
aria-label="password" aria-label="password"
aria-required="true" aria-required="true"
required required
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm" className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value} value={field.value}
onChange={(password) => field.onChange(password)} onChange={(password) => field.onChange(password)}
/> />

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -17,7 +17,7 @@ const ZUpdateOrganizationNameAction = z.object({
}); });
export const updateOrganizationNameAction = authenticatedActionClient export const updateOrganizationNameAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationNameAction) .schema(ZUpdateOrganizationNameAction)
.action( .action(
withAuditLogging( withAuditLogging(
"updated", "updated",
@@ -55,36 +55,28 @@ const ZDeleteOrganizationAction = z.object({
organizationId: ZId, organizationId: ZId,
}); });
export const deleteOrganizationAction = authenticatedActionClient export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
.inputSchema(ZDeleteOrganizationAction) withAuditLogging(
.action( "deleted",
withAuditLogging( "organization",
"deleted", async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
"organization", const isMultiOrgEnabled = await getIsMultiOrgEnabled();
async ({ if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId: parsedInput.organizationId, organizationId: parsedInput.organizationId,
access: [ access: [
{ {
type: "organization", type: "organization",
roles: ["owner"], roles: ["owner"],
}, },
], ],
}); });
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId); const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject; ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId); return await deleteOrganization(parsedInput.organizationId);
} }
) )
); );

View File

@@ -9,7 +9,6 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { IdBadge } from "@/modules/ui/components/id-badge"; import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard"; import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization"; import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm"; import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
@@ -82,10 +81,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard> </SettingsCard>
)} )}
<div className="space-y-2"> <IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
</div>
</PageContentWrapper> </PageContentWrapper>
); );
}; };

View File

@@ -4,7 +4,6 @@ import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ZResponseFilterCriteria } from "@formbricks/types/responses"; import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service"; import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -23,7 +22,7 @@ const ZGetResponsesAction = z.object({
}); });
export const getResponsesAction = authenticatedActionClient export const getResponsesAction = authenticatedActionClient
.inputSchema(ZGetResponsesAction) .schema(ZGetResponsesAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -57,7 +56,7 @@ const ZGetSurveySummaryAction = z.object({
}); });
export const getSurveySummaryAction = authenticatedActionClient export const getSurveySummaryAction = authenticatedActionClient
.inputSchema(ZGetSurveySummaryAction) .schema(ZGetSurveySummaryAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -85,7 +84,7 @@ const ZGetResponseCountAction = z.object({
}); });
export const getResponseCountAction = authenticatedActionClient export const getResponseCountAction = authenticatedActionClient
.inputSchema(ZGetResponseCountAction) .schema(ZGetResponseCountAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -107,31 +106,3 @@ export const getResponseCountAction = authenticatedActionClient
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria); return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
}); });
const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId,
limit: z.int().min(1).max(100),
offset: z.int().nonnegative(),
});
export const getDisplaysWithContactAction = authenticatedActionClient
.inputSchema(ZGetDisplaysWithContactAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
return getDisplaysBySurveyIdWithContact(parsedInput.surveyId, parsedInput.limit, parsedInput.offset);
});

View File

@@ -3,7 +3,6 @@ import { getServerSession } from "next-auth";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
type Props = { type Props = {
@@ -15,11 +14,10 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const survey = await getSurvey(params.surveyId); const survey = await getSurvey(params.surveyId);
const responseCount = await getResponseCountBySurveyId(params.surveyId); const responseCount = await getResponseCountBySurveyId(params.surveyId);
const t = await getTranslate();
if (session) { if (session) {
return { return {
title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`, title: `${responseCount} Responses | ${survey?.name} Results`,
}; };
} }
return { return {

View File

@@ -22,7 +22,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
}); });
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
.inputSchema(ZSendEmbedSurveyPreviewEmailAction) .schema(ZSendEmbedSurveyPreviewEmailAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId); const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
@@ -69,7 +69,7 @@ const ZResetSurveyAction = z.object({
projectId: ZId, projectId: ZId,
}); });
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action( export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
withAuditLogging( withAuditLogging(
"updated", "updated",
"survey", "survey",
@@ -123,7 +123,7 @@ const ZGetEmailHtmlAction = z.object({
}); });
export const getEmailHtmlAction = authenticatedActionClient export const getEmailHtmlAction = authenticatedActionClient
.inputSchema(ZGetEmailHtmlAction) .schema(ZGetEmailHtmlAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -152,7 +152,7 @@ const ZGeneratePersonalLinksAction = z.object({
}); });
export const generatePersonalLinksAction = authenticatedActionClient export const generatePersonalLinksAction = authenticatedActionClient
.inputSchema(ZGeneratePersonalLinksAction) .schema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled(); const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) { if (!isContactsEnabled) {
@@ -231,7 +231,7 @@ const ZUpdateSingleUseLinksAction = z.object({
}); });
export const updateSingleUseLinksAction = authenticatedActionClient export const updateSingleUseLinksAction = authenticatedActionClient
.inputSchema(ZUpdateSingleUseLinksAction) .schema(ZUpdateSingleUseLinksAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,

View File

@@ -30,7 +30,8 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.booked.count })} {elementSummary.booked.count}{" "}
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
@@ -46,7 +47,8 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.skipped.count })} {elementSummary.skipped.count}{" "}
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />

View File

@@ -64,7 +64,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: summaryItem.count })} {summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<div className="group-hover:opacity-80"> <div className="group-hover:opacity-80">

View File

@@ -48,7 +48,7 @@ export const ElementSummaryHeader = ({
{showResponses && ( {showResponses && (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_responses", { count: elementSummary.responseCount })} {`${elementSummary.responseCount} ${t("common.responses")}`}
</div> </div>
)} )}
{additionalInfo} {additionalInfo}

View File

@@ -41,7 +41,8 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
</div> </div>
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_responses", { count: elementSummary.responseCount })} {elementSummary.responseCount}{" "}
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -31,7 +31,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
if (label) { if (label) {
return label; return label;
} else if (percentage !== undefined && totalResponsesForRow !== undefined) { } else if (percentage !== undefined && totalResponsesForRow !== undefined) {
return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) }); return `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`;
} }
return ""; return "";
}; };
@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
)}> )}>
<button <button
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }} style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark" className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
onClick={() => onClick={() =>
setFilter( setFilter(
elementSummary.element.id, elementSummary.element.id,

View File

@@ -75,7 +75,7 @@ export const MultipleChoiceSummary = ({
elementSummary.type === "multipleChoiceMulti" ? ( elementSummary.type === "multipleChoiceMulti" ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_selections", { count: elementSummary.selectionCount })} {`${elementSummary.selectionCount} ${t("common.selections")}`}
</div> </div>
) : undefined ) : undefined
} }
@@ -110,7 +110,7 @@ export const MultipleChoiceSummary = ({
</div> </div>
<div className="flex w-full space-x-2"> <div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0"> <p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{t("common.count_selections", { count: result.count })} {result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p> </p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700"> <p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}% {convertFloatToNDecimal(result.percentage, 2)}%

View File

@@ -123,7 +123,8 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary[group]?.count })} {elementSummary[group]?.count}{" "}
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar <ProgressBar
@@ -157,7 +158,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
}> }>
<div className="flex h-32 w-full flex-col items-center justify-end"> <div className="flex h-32 w-full flex-col items-center justify-end">
<div <div
className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110" className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
style={{ style={{
height: `${Math.max(choice.percentage, 2)}%`, height: `${Math.max(choice.percentage, 2)}%`,
opacity, opacity,

View File

@@ -37,7 +37,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
elementSummary.element.allowMulti ? ( elementSummary.element.allowMulti ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_selections", { count: elementSummary.selectionCount })} {`${elementSummary.selectionCount} ${t("common.selections")}`}
</div> </div>
) : undefined ) : undefined
} }
@@ -74,7 +74,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
</div> </div>
<div className="flex w-full space-x-2"> <div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0"> <p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{t("common.count_selections", { count: result.count })} {result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p> </p>
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700"> <p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}% {convertFloatToNDecimal(result.percentage, 2)}%

View File

@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
) )
}> }>
<div <div
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`} className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }} style={{ opacity }}
/> />
</ClickableBarSegment> </ClickableBarSegment>
@@ -198,7 +198,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: result.count })} {result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
@@ -215,7 +215,8 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
<div className="text flex justify-between px-2"> <div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p> <p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.dismissed.count })} {elementSummary.dismissed.count}{" "}
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,125 +0,0 @@
"use client";
import { AlertCircleIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Button } from "@/modules/ui/components/button";
interface SummaryImpressionsProps {
displays: TDisplayWithContact[];
isLoading: boolean;
hasMore: boolean;
displaysError: string | null;
environmentId: string;
locale: TUserLocale;
onLoadMore: () => void;
onRetry: () => void;
}
const getDisplayContactIdentifier = (display: TDisplayWithContact): string => {
if (!display.contact) return "";
return display.contact.attributes?.email || display.contact.attributes?.userId || display.contact.id;
};
export const SummaryImpressions = ({
displays,
isLoading,
hasMore,
displaysError,
environmentId,
locale,
onLoadMore,
onRetry,
}: SummaryImpressionsProps) => {
const { t } = useTranslation();
const renderContent = () => {
if (displaysError) {
return (
<div className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex items-center gap-2 text-red-600">
<AlertCircleIcon className="h-5 w-5" />
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
</div>
<p className="text-sm text-slate-500">{displaysError}</p>
<Button onClick={onRetry} variant="secondary" size="sm">
{t("common.try_again")}
</Button>
</div>
</div>
);
}
if (displays.length === 0) {
return (
<div className="p-8 text-center text-sm text-slate-500">
{t("environments.surveys.summary.no_identified_impressions")}
</div>
);
}
return (
<>
<div className="grid min-h-10 grid-cols-4 items-center border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-2 px-4 md:px-6">{t("common.user")}</div>
<div className="col-span-2 px-4 md:px-6">{t("environments.contacts.survey_viewed_at")}</div>
</div>
<div className="max-h-[62vh] overflow-y-auto">
{displays.map((display) => (
<div
key={display.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-xs text-slate-800 last:border-transparent md:text-sm">
<div className="col-span-2 pl-4 md:pl-6">
{display.contact ? (
<Link
className="ph-no-capture break-all text-slate-600 hover:underline"
href={`/environments/${environmentId}/contacts/${display.contact.id}`}>
{getDisplayContactIdentifier(display)}
</Link>
) : (
<span className="break-all text-slate-600">{t("common.anonymous")}</span>
)}
</div>
<div className="col-span-2 px-4 text-slate-500 md:px-6">
{timeSince(display.createdAt.toString(), locale)}
</div>
</div>
))}
</div>
{hasMore && (
<div className="flex justify-center border-t border-slate-100 py-4">
<Button onClick={onLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</>
);
};
if (isLoading) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
<div className="flex items-center justify-center">
<div className="h-6 w-32 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
);
}
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<InfoIcon className="h-4 w-4 shrink-0" />
<span>{t("environments.surveys.summary.impressions_identified_only")}</span>
</div>
{renderContent()}
</div>
);
};

View File

@@ -10,8 +10,8 @@ interface SummaryMetadataProps {
surveySummary: TSurveySummary["meta"]; surveySummary: TSurveySummary["meta"];
quotasCount: number; quotasCount: number;
isLoading: boolean; isLoading: boolean;
tab: "dropOffs" | "quotas" | "impressions" | undefined; tab: "dropOffs" | "quotas" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>; setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
isQuotasAllowed: boolean; isQuotasAllowed: boolean;
} }
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
const { t } = useTranslation(); const { t } = useTranslation();
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount; const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => { const handleTabChange = (val: "dropOffs" | "quotas") => {
const change = tab === val ? undefined : val; const change = tab === val ? undefined : val;
setTab(change); setTab(change);
}; };
@@ -65,16 +65,12 @@ export const SummaryMetadata = ({
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`, `grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6" isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
)}> )}>
<InteractiveCard <StatCard
key="impressions"
tab="impressions"
label={t("environments.surveys.summary.impressions")} label={t("environments.surveys.summary.impressions")}
percentage={null} percentage={null}
value={displayCount === 0 ? <span>-</span> : displayCount} value={displayCount === 0 ? <span>-</span> : displayCount}
tooltipText={t("environments.surveys.summary.impressions_tooltip")} tooltipText={t("environments.surveys.summary.impressions_tooltip")}
isLoading={isLoading} isLoading={isLoading}
onClick={() => handleTabChange("impressions")}
isActive={tab === "impressions"}
/> />
<StatCard <StatCard
label={t("environments.surveys.summary.starts")} label={t("environments.surveys.summary.starts")}

View File

@@ -1,31 +1,21 @@
"use client"; "use client";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
getDisplaysWithContactAction,
getSurveySummaryAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop"; import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs"; import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import { SummaryImpressions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryImpressions";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys"; import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary"; import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
import { SummaryList } from "./SummaryList"; import { SummaryList } from "./SummaryList";
import { SummaryMetadata } from "./SummaryMetadata"; import { SummaryMetadata } from "./SummaryMetadata";
const DISPLAYS_PER_PAGE = 15;
const defaultSurveySummary: TSurveySummary = { const defaultSurveySummary: TSurveySummary = {
meta: { meta: {
completedPercentage: 0, completedPercentage: 0,
@@ -61,76 +51,17 @@ export const SummaryPage = ({
initialSurveySummary, initialSurveySummary,
isQuotasAllowed, isQuotasAllowed,
}: SummaryPageProps) => { }: SummaryPageProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>( const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
initialSurveySummary || defaultSurveySummary initialSurveySummary || defaultSurveySummary
); );
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined); const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary); const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter(); const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
const [hasMoreDisplays, setHasMoreDisplays] = useState(true);
const [displaysError, setDisplaysError] = useState<string | null>(null);
const displaysFetchedRef = useRef(false);
const fetchDisplays = useCallback(
async (offset: number) => {
const response = await getDisplaysWithContactAction({
surveyId,
limit: DISPLAYS_PER_PAGE,
offset,
});
if (!response?.data) {
const errorMessage = getFormattedErrorMessage(response);
throw new Error(errorMessage);
}
return response?.data ?? [];
},
[surveyId]
);
const loadInitialDisplays = useCallback(async () => {
setIsDisplaysLoading(true);
setDisplaysError(null);
try {
const data = await fetchDisplays(0);
setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
toast.error(error);
setDisplays([]);
setHasMoreDisplays(false);
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays, t]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
const data = await fetchDisplays(displays.length);
setDisplays((prev) => [...prev, ...data]);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
}
}, [fetchDisplays, displays.length, t]);
useEffect(() => {
if (tab === "impressions" && !displaysFetchedRef.current) {
displaysFetchedRef.current = true;
loadInitialDisplays();
}
}, [tab, loadInitialDisplays]);
// Only fetch data when filters change or when there's no initial data // Only fetch data when filters change or when there's no initial data
useEffect(() => { useEffect(() => {
// If we have initial data and no filters are applied, don't fetch // If we have initial data and no filters are applied, don't fetch
@@ -190,18 +121,6 @@ export const SummaryPage = ({
setTab={setTab} setTab={setTab}
isQuotasAllowed={isQuotasAllowed} isQuotasAllowed={isQuotasAllowed}
/> />
{tab === "impressions" && (
<SummaryImpressions
displays={displays}
isLoading={isDisplaysLoading}
hasMore={hasMoreDisplays}
displaysError={displaysError}
environmentId={environment.id}
locale={locale}
onLoadMore={handleLoadMoreDisplays}
onRetry={loadInitialDisplays}
/>
)}
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />} {tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />} {isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
<div className="flex gap-1.5"> <div className="flex gap-1.5">

View File

@@ -4,9 +4,9 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card"; import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
interface InteractiveCardProps { interface InteractiveCardProps {
tab: "dropOffs" | "quotas" | "impressions"; tab: "dropOffs" | "quotas";
label: string; label: string;
percentage: number | null; percentage: number;
value: React.ReactNode; value: React.ReactNode;
tooltipText: string; tooltipText: string;
isLoading: boolean; isLoading: boolean;

View File

@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
}, },
{ {
title: t("environments.surveys.share.anonymous_links.custom_start_point"), title: t("environments.surveys.share.anonymous_links.custom_start_point"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block", href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
}, },
]} ]}
/> />

View File

@@ -105,7 +105,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
<div className={scriptsMode === "replace" ? "opacity-50" : ""}> <div className={scriptsMode === "replace" ? "opacity-50" : ""}>
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel> <FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3"> <div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600"> <pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
{projectCustomScripts} {projectCustomScripts}
</pre> </pre>
</div> </div>
@@ -135,7 +135,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
rows={8} rows={8}
placeholder={t("environments.surveys.share.custom_html.placeholder")} placeholder={t("environments.surveys.share.custom_html.placeholder")}
className={cn( className={cn(
"flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" "focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
)} )}
{...field} {...field}
disabled={isReadOnly} disabled={isReadOnly}

View File

@@ -66,7 +66,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8"> className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" /> <UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.use_personal_links")} {t("environments.surveys.summary.use_personal_links")}
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} /> <Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
</button> </button>
<Link <Link
href={`/environments/${environmentId}/settings/notifications`} href={`/environments/${environmentId}/settings/notifications`}

View File

@@ -1095,7 +1095,7 @@ export const getResponsesForSummary = reactCache(
[limit, ZOptionalNumber], [limit, ZOptionalNumber],
[offset, ZOptionalNumber], [offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()], [filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.cuid2().optional()] [cursor, z.string().cuid2().optional()]
); );
const queryLimit = limit ?? RESPONSES_PER_PAGE; const queryLimit = limit ?? RESPONSES_PER_PAGE;

View File

@@ -28,7 +28,7 @@ const ZGetResponsesDownloadUrlAction = z.object({
}); });
export const getResponsesDownloadUrlAction = authenticatedActionClient export const getResponsesDownloadUrlAction = authenticatedActionClient
.inputSchema(ZGetResponsesDownloadUrlAction) .schema(ZGetResponsesDownloadUrlAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -58,7 +58,7 @@ const ZGetSurveyFilterDataAction = z.object({
}); });
export const getSurveyFilterDataAction = authenticatedActionClient export const getSurveyFilterDataAction = authenticatedActionClient
.inputSchema(ZGetSurveyFilterDataAction) .schema(ZGetSurveyFilterDataAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
const survey = await getSurvey(parsedInput.surveyId); const survey = await getSurvey(parsedInput.surveyId);
@@ -121,7 +121,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
} }
}; };
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action( export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
withAuditLogging( withAuditLogging(
"updated", "updated",
"survey", "survey",

View File

@@ -192,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
value={inputValue} value={inputValue}
onValueChange={setInputValue} onValueChange={setInputValue}
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")} placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0" className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
/> />
)} )}
<Button <Button

View File

@@ -241,7 +241,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<Popover open={isOpen} onOpenChange={handleOpenChange}> <Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<PopoverTriggerButton isOpen={isOpen}> <PopoverTriggerButton isOpen={isOpen}>
{t("common.filter")} <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b> Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
</PopoverTriggerButton> </PopoverTriggerButton>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
@@ -329,7 +329,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
</div> </div>
{i !== filterValue.filter.length - 1 && ( {i !== filterValue.filter.length - 1 && (
<div className="my-4 flex items-center"> <div className="my-4 flex items-center">
<p className="mr-4 font-semibold text-slate-800">{t("common.and")}</p> <p className="mr-4 font-semibold text-slate-800">and</p>
<hr className="w-full text-slate-600" /> <hr className="w-full text-slate-600" />
</div> </div>
)} )}

View File

@@ -1,208 +0,0 @@
"use client";
import { CheckCircle2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
const FORMBRICKS_HOST = "https://app.formbricks.com";
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
interface WorkflowsPageProps {
userEmail: string;
organizationName: string;
billingPlan: string;
}
type Step = "prompt" | "followup" | "thankyou";
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("prompt");
const [promptValue, setPromptValue] = useState("");
const [detailsValue, setDetailsValue] = useState("");
const [responseId, setResponseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleGenerateWorkflow = async () => {
if (promptValue.trim().length < 100 || isSubmitting) return;
setIsSubmitting(true);
try {
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
surveyId: SURVEY_ID,
finished: false,
data: {
workflow: promptValue.trim(),
useremail: userEmail,
orgname: organizationName,
billingplan: billingPlan,
},
}),
});
if (res.ok) {
const json = await res.json();
setResponseId(json.data?.id ?? null);
}
setStep("followup");
} catch {
setStep("followup");
} finally {
setIsSubmitting(false);
}
};
const handleSubmitFeedback = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
if (responseId) {
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {
details: detailsValue.trim(),
},
}),
});
} catch {
// silently fail
}
}
setIsSubmitting(false);
setStep("thankyou");
};
const handleSkipFeedback = async () => {
if (!responseId) {
setStep("thankyou");
return;
}
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {},
}),
});
} catch {
// silently fail
}
setStep("thankyou");
};
if (step === "prompt") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="from-brand-light to-brand-dark mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br shadow-md">
<Sparkles className="h-6 w-6 text-white" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
</div>
<div className="relative">
<textarea
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
placeholder={t("workflows.placeholder")}
rows={5}
className="focus:border-brand-dark focus:ring-brand-light/20 w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:outline-none focus:ring-2"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleGenerateWorkflow();
}
}}
/>
<div className="mt-3 flex items-center justify-between">
<span
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
{promptValue.trim().length} / 100
</span>
<Button
onClick={handleGenerateWorkflow}
disabled={promptValue.trim().length < 100 || isSubmitting}
loading={isSubmitting}
size="lg">
<Sparkles className="h-4 w-4" />
{t("workflows.generate_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
if (step === "followup") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
<Sparkles className="text-brand-dark h-6 w-6" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
{t("workflows.coming_soon_title")}
</h1>
<p className="mx-auto max-w-md text-base text-slate-500">
{t("workflows.coming_soon_description")}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<label className="text-md mb-2 block font-medium text-slate-700">
{t("workflows.follow_up_label")}
</label>
<textarea
value={detailsValue}
onChange={(e) => setDetailsValue(e.target.value)}
placeholder={t("workflows.follow_up_placeholder")}
rows={4}
className="focus:border-brand-dark focus:ring-brand-light/20 w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:bg-white focus:outline-none focus:ring-2"
/>
<div className="mt-4 flex items-center justify-end gap-3">
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
{t("common.skip")}
</Button>
<Button
onClick={handleSubmitFeedback}
disabled={!detailsValue.trim() || isSubmitting}
loading={isSubmitting}>
{t("workflows.submit_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-md space-y-6 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
</div>
</div>
);
};

View File

@@ -1,39 +0,0 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { WorkflowsPage } from "./components/workflows-page";
export const metadata: Metadata = {
title: "Workflows",
};
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const user = await getUser(session.user.id);
if (!user) {
return redirect("/auth/login");
}
return (
<WorkflowsPage
userEmail={user.email}
organizationName={organization.name}
billingPlan={organization.billing.plan}
/>
);
};
export default Page;

View File

@@ -21,7 +21,7 @@ const ZCreateOrUpdateIntegrationAction = z.object({
}); });
export const createOrUpdateIntegrationAction = authenticatedActionClient export const createOrUpdateIntegrationAction = authenticatedActionClient
.inputSchema(ZCreateOrUpdateIntegrationAction) .schema(ZCreateOrUpdateIntegrationAction)
.action( .action(
withAuditLogging( withAuditLogging(
"createdUpdated", "createdUpdated",
@@ -67,7 +67,7 @@ const ZDeleteIntegrationAction = z.object({
integrationId: ZId, integrationId: ZId,
}); });
export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDeleteIntegrationAction).action( export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action(
withAuditLogging( withAuditLogging(
"deleted", "deleted",
"integration", "integration",

View File

@@ -1,49 +1,12 @@
"use server"; "use server";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
TIntegrationGoogleSheets,
ZIntegrationGoogleSheets,
} from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/googleSheet/service";
import { getIntegrationByType } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper"; import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
const ZValidateGoogleSheetsConnectionAction = z.object({
environmentId: ZId,
});
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.inputSchema(ZValidateGoogleSheetsConnectionAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
if (!integration) {
return { data: false };
}
await validateGoogleSheetsConnection(integration as TIntegrationGoogleSheets);
return { data: true };
});
const ZGetSpreadsheetNameByIdAction = z.object({ const ZGetSpreadsheetNameByIdAction = z.object({
googleSheetIntegration: ZIntegrationGoogleSheets, googleSheetIntegration: ZIntegrationGoogleSheets,
environmentId: z.string(), environmentId: z.string(),
@@ -51,7 +14,7 @@ const ZGetSpreadsheetNameByIdAction = z.object({
}); });
export const getSpreadsheetNameByIdAction = authenticatedActionClient export const getSpreadsheetNameByIdAction = authenticatedActionClient
.inputSchema(ZGetSpreadsheetNameByIdAction) .schema(ZGetSpreadsheetNameByIdAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,

View File

@@ -20,10 +20,6 @@ import {
isValidGoogleSheetsUrl, isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util"; } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png"; import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
} from "@/lib/googleSheet/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; 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";
@@ -122,17 +118,6 @@ export const AddIntegrationModal = ({
resetForm(); resetForm();
}, [selectedIntegration, surveys]); }, [selectedIntegration, surveys]);
const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => {
const errorMessage = getFormattedErrorMessage(response);
if (errorMessage === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
toast.error(t("environments.integrations.google_sheets.token_expired_error"));
} else if (errorMessage === GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION) {
toast.error(t("environments.integrations.google_sheets.spreadsheet_permission_error"));
} else {
toast.error(errorMessage);
}
};
const linkSheet = async () => { const linkSheet = async () => {
try { try {
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) { if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
@@ -144,7 +129,6 @@ export const AddIntegrationModal = ({
if (selectedElements.length === 0) { if (selectedElements.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error")); throw new Error(t("environments.integrations.select_at_least_one_question_error"));
} }
setIsLinkingSheet(true);
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl); const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({ const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
googleSheetIntegration, googleSheetIntegration,
@@ -153,11 +137,13 @@ export const AddIntegrationModal = ({
}); });
if (!spreadsheetNameResponse?.data) { if (!spreadsheetNameResponse?.data) {
showErrorMessageToast(spreadsheetNameResponse); const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
return; throw new Error(errorMessage);
} }
const spreadsheetName = spreadsheetNameResponse.data; const spreadsheetName = spreadsheetNameResponse.data;
setIsLinkingSheet(true);
integrationData.spreadsheetId = spreadsheetId; integrationData.spreadsheetId = spreadsheetId;
integrationData.spreadsheetName = spreadsheetName; integrationData.spreadsheetName = spreadsheetName;
integrationData.surveyId = selectedSurvey.id; integrationData.surveyId = selectedSurvey.id;

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { import {
TIntegrationGoogleSheets, TIntegrationGoogleSheets,
@@ -8,11 +8,9 @@ import {
} from "@formbricks/types/integration/google-sheet"; } from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration"; import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google"; import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png"; import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration"; import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { AddIntegrationModal } from "./AddIntegrationModal"; import { AddIntegrationModal } from "./AddIntegrationModal";
@@ -37,23 +35,10 @@ export const GoogleSheetWrapper = ({
googleSheetIntegration ? googleSheetIntegration.config?.key : false googleSheetIntegration ? googleSheetIntegration.config?.key : false
); );
const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
const [selectedIntegration, setSelectedIntegration] = useState< const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationGoogleSheetsConfigData & { index: number }) | null (TIntegrationGoogleSheetsConfigData & { index: number }) | null
>(null); >(null);
const validateConnection = useCallback(async () => {
if (!isConnected || !googleSheetIntegration) return;
const response = await validateGoogleSheetsConnectionAction({ environmentId: environment.id });
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
setShowReconnectButton(true);
}
}, [environment.id, isConnected, googleSheetIntegration]);
useEffect(() => {
validateConnection();
}, [validateConnection]);
const handleGoogleAuthorization = async () => { const handleGoogleAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => { authorize(environment.id, webAppUrl).then((url: string) => {
if (url) { if (url) {
@@ -79,8 +64,6 @@ export const GoogleSheetWrapper = ({
setOpenAddIntegrationModal={setIsModalOpen} setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected} setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration} setSelectedIntegration={setSelectedIntegration}
showReconnectButton={showReconnectButton}
handleGoogleAuthorization={handleGoogleAuthorization}
locale={locale} locale={locale}
/> />
</> </>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react"; import { Trash2Icon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -12,19 +12,15 @@ import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions"; import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, 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";
import { EmptyState } from "@/modules/ui/components/empty-state"; import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ManageIntegrationProps { interface ManageIntegrationProps {
googleSheetIntegration: TIntegrationGoogleSheets; googleSheetIntegration: TIntegrationGoogleSheets;
setOpenAddIntegrationModal: (v: boolean) => void; setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void; setIsConnected: (v: boolean) => void;
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void; setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
showReconnectButton: boolean;
handleGoogleAuthorization: () => void;
locale: TUserLocale; locale: TUserLocale;
} }
@@ -33,8 +29,6 @@ export const ManageIntegration = ({
setOpenAddIntegrationModal, setOpenAddIntegrationModal,
setIsConnected, setIsConnected,
setSelectedIntegration, setSelectedIntegration,
showReconnectButton,
handleGoogleAuthorization,
locale, locale,
}: ManageIntegrationProps) => { }: ManageIntegrationProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -74,17 +68,7 @@ export const ManageIntegration = ({
return ( return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6"> <div className="mt-6 flex w-full flex-col items-center justify-center p-6">
{showReconnectButton && ( <div className="flex w-full justify-end">
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>
{t("environments.integrations.google_sheets.reconnect_button_description")}
</AlertDescription>
<AlertButton onClick={handleGoogleAuthorization}>
{t("environments.integrations.google_sheets.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center"> <div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span> <span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500"> <span className="text-slate-500">
@@ -93,19 +77,6 @@ export const ManageIntegration = ({
})} })}
</span> </span>
</div> </div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleGoogleAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.google_sheets.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("environments.integrations.google_sheets.reconnect_button_tooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button <Button
onClick={() => { onClick={() => {
setSelectedIntegration(null); setSelectedIntegration(null);

View File

@@ -10,7 +10,7 @@ const Loading = () => {
<div className="mt-6 p-6"> <div className="mt-6 p-6">
<GoBackButton /> <GoBackButton />
<div className="mb-6 text-right"> <div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200"> <Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
{t("environments.integrations.google_sheets.link_new_sheet")} {t("environments.integrations.google_sheets.link_new_sheet")}
</Button> </Button>
</div> </div>
@@ -51,7 +51,7 @@ const Loading = () => {
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div> <div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
</div> </div>
</div> </div>
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500"> <div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div> <div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
</div> </div>
<div className="text-center"></div> <div className="text-center"></div>

View File

@@ -10,7 +10,7 @@ const Loading = () => {
<div className="mt-6 p-6"> <div className="mt-6 p-6">
<GoBackButton /> <GoBackButton />
<div className="mb-6 text-right"> <div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200"> <Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
{t("environments.integrations.notion.link_database")} {t("environments.integrations.notion.link_database")}
</Button> </Button>
</div> </div>
@@ -48,7 +48,7 @@ const Loading = () => {
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div> <div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
</div> </div>
</div> </div>
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500"> <div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div> <div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
</div> </div>
<div className="text-center"></div> <div className="text-center"></div>

View File

@@ -12,7 +12,7 @@ const ZGetSlackChannelsAction = z.object({
}); });
export const getSlackChannelsAction = authenticatedActionClient export const getSlackChannelsAction = authenticatedActionClient
.inputSchema(ZGetSlackChannelsAction) .schema(ZGetSlackChannelsAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,

View File

@@ -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);
} }
}); });

View File

@@ -15,11 +15,9 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time"; import { convertDatesInObject } from "@/lib/time";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
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";
@@ -32,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);
@@ -97,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,
@@ -136,17 +128,13 @@ export const POST = async (request: Request) => {
); );
} }
return validateWebhookUrl(webhook.url) return fetchWithTimeout(webhook.url, {
.then(() => method: "POST",
fetchWithTimeout(webhook.url, { headers: requestHeaders,
method: "POST", body,
headers: requestHeaders, }).catch((error) => {
body, logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
}) });
)
.catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
}); });
if (event === "responseFinished") { if (event === "responseFinished") {

View File

@@ -1,6 +1,4 @@
import { google } from "googleapis"; import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { import {
GOOGLE_SHEETS_CLIENT_ID, GOOGLE_SHEETS_CLIENT_ID,
@@ -8,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, getIntegrationByType } 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");
} }
@@ -43,39 +30,33 @@ export const GET = async (req: Request) => {
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing"); if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri); const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
if (!code) { let key;
return Response.redirect( let userEmail;
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
); if (code) {
const token = await oAuth2Client.getToken(code);
key = token.res?.data;
// Set credentials using the provided token
oAuth2Client.setCredentials({
access_token: key.access_token,
});
// Fetch user's email
const oauth2 = google.oauth2({
auth: oAuth2Client,
version: "v2",
});
const userInfo = await oauth2.userinfo.get();
userEmail = userInfo.data.email;
} }
const token = await oAuth2Client.getToken(code);
const key = token.res?.data;
if (!key) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
}
oAuth2Client.setCredentials({ access_token: key.access_token });
const oauth2 = google.oauth2({ auth: oAuth2Client, version: "v2" });
const userInfo = await oauth2.userinfo.get();
const userEmail = userInfo.data.email;
if (!userEmail) {
return responses.internalServerErrorResponse("Failed to get user email");
}
const integrationType = "googleSheets" as const;
const existingIntegration = await getIntegrationByType(environmentId, integrationType);
const existingConfig = existingIntegration?.config as TIntegrationGoogleSheetsConfig;
const googleSheetIntegration = { const googleSheetIntegration = {
type: integrationType, type: "googleSheets" as "googleSheets",
environment: environmentId, environment: environmentId,
config: { config: {
key, key,
data: existingConfig?.data ?? [], data: [],
email: userEmail, email: userEmail,
}, },
}; };

View File

@@ -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
),
};
}
},
});

View File

@@ -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();
});
});

View File

@@ -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;
}
);

View File

@@ -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,
overlay: "none",
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.
});
});

View File

@@ -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;
}
}
);

View File

@@ -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();
});
});

View File

@@ -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;
};

View File

@@ -0,0 +1,6 @@
import {
OPTIONS,
PUT,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
export { OPTIONS, PUT };

View File

@@ -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";
/** /**
@@ -178,14 +177,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
overlay: environmentData.project.overlay, overlay: environmentData.project.overlay,
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) {

View File

@@ -50,7 +50,7 @@ export const GET = withV1ApiWrapper({
{ {
environmentId: params.environmentId, environmentId: params.environmentId,
url: req.url, url: req.url,
validationError: cuidValidation.error.issues[0]?.message, validationError: cuidValidation.error.errors[0]?.message,
}, },
"Invalid CUID v1 format detected" "Invalid CUID v1 format detected"
); );

View File

@@ -0,0 +1,6 @@
import {
GET,
OPTIONS,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
export { GET, OPTIONS };

View File

@@ -44,10 +44,13 @@ const validateResponse = (
...responseUpdateInput.data, ...responseUpdateInput.data,
}; };
const isFinished = responseUpdateInput.finished ?? false;
const validationErrors = validateResponseData( const validationErrors = validateResponseData(
survey.blocks, survey.blocks,
mergedData, mergedData,
responseUpdateInput.language ?? response.language ?? "en", responseUpdateInput.language ?? response.language ?? "en",
isFinished,
survey.questions survey.questions
); );

View File

@@ -41,6 +41,7 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
survey.blocks, survey.blocks,
responseInputData.data, responseInputData.data,
responseInputData.language ?? "en", responseInputData.language ?? "en",
responseInputData.finished,
survey.questions survey.questions
); );

View File

@@ -6,138 +6,140 @@ export const GET = async (req: NextRequest) => {
let brandColor = req.nextUrl.searchParams.get("brandColor"); let brandColor = req.nextUrl.searchParams.get("brandColor");
return new ImageResponse( return new ImageResponse(
<div (
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
alignItems: "center",
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
borderRadius: "0.75rem",
}}>
<div <div
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
width: "80%", width: "100%",
height: "60%", height: "100%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3.25rem",
position: "absolute",
left: "3rem",
top: "0.75rem",
opacity: 0.2,
transform: "rotate(356deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "84%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3rem",
position: "absolute",
top: "1.25rem",
left: "3.25rem",
borderWidth: "2px",
opacity: 0.6,
transform: "rotate(357deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "85%",
height: "67%",
alignItems: "center", alignItems: "center",
backgroundColor: "white", backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
borderRadius: "0.75rem", borderRadius: "0.75rem",
marginTop: "2rem",
position: "absolute",
top: "2.3rem",
left: "3.5rem",
transform: "rotate(360deg)",
}}> }}>
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}> <div
<div style={{
style={{ display: "flex",
display: "flex", flexDirection: "column",
flexDirection: "column", width: "80%",
width: "100%", height: "60%",
justifyContent: "space-between", backgroundColor: "white",
}}> borderRadius: "0.75rem",
marginTop: "3.25rem",
position: "absolute",
left: "3rem",
top: "0.75rem",
opacity: 0.2,
transform: "rotate(356deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "84%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3rem",
position: "absolute",
top: "1.25rem",
left: "3.25rem",
borderWidth: "2px",
opacity: 0.6,
transform: "rotate(357deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "85%",
height: "67%",
alignItems: "center",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "2rem",
position: "absolute",
top: "2.3rem",
left: "3.5rem",
transform: "rotate(360deg)",
}}>
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
<div <div
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
paddingLeft: "2rem", width: "100%",
paddingRight: "2rem", justifyContent: "space-between",
}}> }}>
<h2 <div
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
fontSize: "2rem", paddingLeft: "2rem",
fontWeight: "700", paddingRight: "2rem",
letterSpacing: "-0.025em",
color: "#0f172a",
textAlign: "left",
marginTop: "3.75rem",
}}> }}>
{name} <h2
</h2> style={{
display: "flex",
flexDirection: "column",
fontSize: "2rem",
fontWeight: "700",
letterSpacing: "-0.025em",
color: "#0f172a",
textAlign: "left",
marginTop: "3.75rem",
}}>
{name}
</h2>
</div>
</div> </div>
</div> <div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
<div
style={{
display: "flex",
borderRadius: "1rem",
position: "absolute",
right: "-0.5rem",
marginTop: "0.5rem",
}}>
<div
content=""
style={{
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
height: "4.5rem",
width: "9.5rem",
opacity: 0.5,
}}></div>
</div>
<div
style={{
display: "flex",
borderRadius: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
}}>
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", borderRadius: "1rem",
justifyContent: "center", position: "absolute",
borderRadius: "0.75rem", right: "-0.5rem",
border: "1px solid transparent", marginTop: "0.5rem",
backgroundColor: brandColor ?? "#000",
fontSize: "1.5rem",
color: "white",
height: "4.5rem",
width: "9.5rem",
}}> }}>
Begin! <div
content=""
style={{
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
height: "4.5rem",
width: "9.5rem",
opacity: 0.5,
}}></div>
</div>
<div
style={{
display: "flex",
borderRadius: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
}}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
fontSize: "1.5rem",
color: "white",
height: "4.5rem",
width: "9.5rem",
}}>
Begin!
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>, ),
{ {
width: 800, width: 800,
height: 400, height: 400,

View File

@@ -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"),

View File

@@ -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, SLACK_REDIRECT_URI, 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"),
@@ -56,7 +42,6 @@ export const GET = withV1ApiWrapper({
code, code,
client_id: SLACK_CLIENT_ID, client_id: SLACK_CLIENT_ID,
client_secret: SLACK_CLIENT_SECRET, client_secret: SLACK_CLIENT_SECRET,
redirect_uri: SLACK_REDIRECT_URI,
}; };
const formBody: string[] = []; const formBody: string[] = [];
for (const property in formData) { for (const property in formData) {

View File

@@ -10,7 +10,7 @@ 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/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 +57,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 {
@@ -149,6 +146,7 @@ export const PUT = withV1ApiWrapper({
result.survey.blocks, result.survey.blocks,
responseUpdate.data, responseUpdate.data,
responseUpdate.language ?? "en", responseUpdate.language ?? "en",
responseUpdate.finished,
result.survey.questions result.survey.questions
); );
@@ -192,7 +190,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 {

View File

@@ -9,7 +9,7 @@ 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/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 +54,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) {
@@ -157,6 +155,7 @@ export const POST = withV1ApiWrapper({
surveyResult.survey.blocks, surveyResult.survey.blocks,
responseInput.data, responseInput.data,
responseInput.language ?? "en", responseInput.language ?? "en",
responseInput.finished,
surveyResult.survey.questions surveyResult.survey.questions
); );

View File

@@ -6,7 +6,7 @@ import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
export const deleteSurvey = async (surveyId: string) => { export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, z.cuid2()]); validateInputs([surveyId, z.string().cuid2()]);
try { try {
const deletedSurvey = await prisma.survey.delete({ const deletedSurvey = await prisma.survey.delete({

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -2,11 +2,10 @@ import { Prisma, WebhookSource } from "@prisma/client";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors"; import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook"; import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook";
import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks"; import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
prisma: { prisma: {
@@ -24,10 +23,6 @@ vi.mock("@/lib/crypto", () => ({
generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"), generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"),
})); }));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(undefined),
}));
describe("createWebhook", () => { describe("createWebhook", () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -80,41 +75,6 @@ describe("createWebhook", () => {
expect(result).toEqual(createdWebhook); expect(result).toEqual(createdWebhook);
}); });
test("should call validateWebhookUrl with the provided URL", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1"],
};
vi.mocked(prisma.webhook.create).mockResolvedValueOnce({} as any);
await createWebhook(webhookInput);
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com");
});
test("should throw InvalidInputError and skip Prisma create when URL fails SSRF validation", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "http://169.254.169.254/latest/meta-data/",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1"],
};
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(
new InvalidInputError("Webhook URL must not point to private or internal IP addresses")
);
await expect(createWebhook(webhookInput)).rejects.toThrow(InvalidInputError);
expect(prisma.webhook.create).not.toHaveBeenCalled();
});
test("should throw a ValidationError if the input data does not match the ZWebhookInput schema", async () => { test("should throw a ValidationError if the input data does not match the ZWebhookInput schema", async () => {
const invalidWebhookInput = { const invalidWebhookInput = {
environmentId: "test-env-id", environmentId: "test-env-id",

View File

@@ -6,11 +6,9 @@ import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhoo
import { ITEMS_PER_PAGE } from "@/lib/constants"; import { ITEMS_PER_PAGE } from "@/lib/constants";
import { generateWebhookSecret } from "@/lib/crypto"; import { generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => { export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
validateInputs([webhookInput, ZWebhookInput]); validateInputs([webhookInput, ZWebhookInput]);
await validateWebhookUrl(webhookInput.url);
try { try {
const secret = generateWebhookSecret(); const secret = generateWebhookSecret();

View File

@@ -0,0 +1,6 @@
import {
OPTIONS,
PUT,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
export { OPTIONS, PUT };

View File

@@ -0,0 +1,6 @@
import {
GET,
OPTIONS,
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
export { GET, OPTIONS };

View File

@@ -101,9 +101,7 @@ describe("verifyRecaptchaToken", () => {
}, },
signal: {}, signal: {},
}; };
vi.spyOn(global, "AbortController").mockImplementation(function AbortController() { vi.spyOn(global, "AbortController").mockImplementation(() => abortController as any);
return abortController as any;
});
(global.fetch as any).mockImplementation(() => new Promise(() => {})); (global.fetch as any).mockImplementation(() => new Promise(() => {}));
verifyRecaptchaToken("token", 0.5); verifyRecaptchaToken("token", 0.5);
vi.advanceTimersByTime(5000); vi.advanceTimersByTime(5000);

View File

@@ -112,6 +112,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
survey.blocks, survey.blocks,
responseInputData.data, responseInputData.data,
responseInputData.language ?? "en", responseInputData.language ?? "en",
responseInputData.finished,
survey.questions survey.questions
); );

View File

@@ -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">

View File

@@ -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 },
}, },
}, },
})); }));
@@ -131,11 +133,13 @@ describe("withV1ApiWrapper", () => {
}); });
test("logs and audits on error response with API key authentication", async () => { test("logs and audits on error response with API key authentication", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock }; "@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
await import("@/app/middleware/endpoint-validator"); "@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -183,11 +187,13 @@ describe("withV1ApiWrapper", () => {
}); });
test("does not log Sentry if not 500", async () => { test("does not log Sentry if not 500", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock }; "@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
await import("@/app/middleware/endpoint-validator"); "@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -229,11 +235,13 @@ describe("withV1ApiWrapper", () => {
}); });
test("logs and audits on thrown error", async () => { test("logs and audits on thrown error", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock }; "@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
await import("@/app/middleware/endpoint-validator"); "@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -285,11 +293,13 @@ describe("withV1ApiWrapper", () => {
}); });
test("does not log on success response but still audits", async () => { test("does not log on success response but still audits", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock }; "@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
await import("@/app/middleware/endpoint-validator"); "@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -339,11 +349,13 @@ describe("withV1ApiWrapper", () => {
REDIS_URL: "redis://localhost:6379", REDIS_URL: "redis://localhost:6379",
})); }));
const { queueAuditEvent: mockedQueueAuditEvent } = const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock }; "@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
await import("@/app/middleware/endpoint-validator"); "@/app/middleware/endpoint-validator"
);
const { withV1ApiWrapper } = await import("./with-api-logging"); const { withV1ApiWrapper } = await import("./with-api-logging");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
@@ -366,8 +378,9 @@ describe("withV1ApiWrapper", () => {
}); });
test("handles client-side API routes without authentication", async () => { test("handles client-side API routes without authentication", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
await import("@/app/middleware/endpoint-validator"); "@/app/middleware/endpoint-validator"
);
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers"); const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
@@ -399,8 +412,9 @@ describe("withV1ApiWrapper", () => {
}); });
test("returns authentication error for non-client routes without auth", async () => { test("returns authentication error for non-client routes without auth", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
await import("@/app/middleware/endpoint-validator"); "@/app/middleware/endpoint-validator"
);
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -423,8 +437,9 @@ describe("withV1ApiWrapper", () => {
test("handles rate limiting errors", async () => { test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers"); const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
await import("@/app/middleware/endpoint-validator"); "@/app/middleware/endpoint-validator"
);
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
@@ -448,12 +463,53 @@ describe("withV1ApiWrapper", () => {
expect(handler).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled();
}); });
test("skips audit log creation when no action/targetType provided", async () => { test("handles sync user identification rate limiting", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = const { applyRateLimit, applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock }; const {
isClientSideApiRoute,
isManagementApiRoute,
isIntegrationRoute,
isSyncWithUserIdentificationEndpoint,
} = await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth"); const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator"); 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 () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });

View File

@@ -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;
} }

View File

@@ -1,4 +1,4 @@
import * as cuid2 from "@paralleldrive/cuid2"; import cuid2 from "@paralleldrive/cuid2";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as crypto from "@/lib/crypto"; import * as crypto from "@/lib/crypto";
import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUseSurveys"; import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUseSurveys";
@@ -20,6 +20,10 @@ vi.mock("@paralleldrive/cuid2", () => {
const isCuidMock = vi.fn(); const isCuidMock = vi.fn();
return { return {
default: {
createId: createIdMock,
isCuid: isCuidMock,
},
createId: createIdMock, createId: createIdMock,
isCuid: isCuidMock, isCuid: isCuidMock,
}; };

View File

@@ -1,10 +1,10 @@
import { createId, isCuid } from "@paralleldrive/cuid2"; import cuid2 from "@paralleldrive/cuid2";
import { ENCRYPTION_KEY } from "@/lib/constants"; import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto"; import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
// generate encrypted single use id for the survey // generate encrypted single use id for the survey
export const generateSurveySingleUseId = (isEncrypted: boolean): string => { export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
const cuid = createId(); const cuid = cuid2.createId();
if (!isEncrypted) { if (!isEncrypted) {
return cuid; return cuid;
} }
@@ -30,7 +30,7 @@ export const validateSurveySingleUseId = (surveySingleUseId: string): string | u
return undefined; return undefined;
} }
if (isCuid(decryptedCuid)) { if (cuid2.isCuid(decryptedCuid)) {
return decryptedCuid; return decryptedCuid;
} else { } else {
return undefined; return undefined;

Some files were not shown because too many files have changed in this diff Show More