mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-14 11:10:45 -05:00
Compare commits
68 Commits
dashboards
...
cursor/inp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ebc2c4110 | ||
|
|
a944d7626e | ||
|
|
d1b12dc228 | ||
|
|
9f7d6038b1 | ||
|
|
1da92addd2 | ||
|
|
1e4aa5f54b | ||
|
|
96f173c3b1 | ||
|
|
9c9e55fba6 | ||
|
|
42541f86fd | ||
|
|
0ba469a73d | ||
|
|
afa192e5b9 | ||
|
|
4860a9a5cf | ||
|
|
af02ce9ea6 | ||
|
|
fc1c91896a | ||
|
|
f5c7dbdc71 | ||
|
|
b88ea5cc66 | ||
|
|
f31085a9e7 | ||
|
|
2ab0441404 | ||
|
|
299ae81b21 | ||
|
|
f73f13f16c | ||
|
|
e9bcbf6e4c | ||
|
|
32eda35a71 | ||
|
|
84999cddfd | ||
|
|
f0a0cf531a | ||
|
|
f3e02fa466 | ||
|
|
f0a93ae092 | ||
|
|
1c922dfe2c | ||
|
|
33010fb6f5 | ||
|
|
d5fdacadd7 | ||
|
|
d939263472 | ||
|
|
e4aa66b067 | ||
|
|
ffcc101ed9 | ||
|
|
2740cd16b9 | ||
|
|
7eb94f0bd5 | ||
|
|
6dd2e707fe | ||
|
|
58d5de7d45 | ||
|
|
7c3fa8b5ea | ||
|
|
2601169877 | ||
|
|
aecf85815a | ||
|
|
c6ebaea989 | ||
|
|
68c1422733 | ||
|
|
6942502baf | ||
|
|
a4bd217761 | ||
|
|
fee770358c | ||
|
|
44f8f80cac | ||
|
|
858a7f7aa9 | ||
|
|
ac40b90e81 | ||
|
|
aa21b4e442 | ||
|
|
fa72296de5 | ||
|
|
3776b31794 | ||
|
|
5c7ea33fb0 | ||
|
|
33f60ce2be | ||
|
|
c0386cea5a | ||
|
|
7cea53130c | ||
|
|
0636989d67 | ||
|
|
219883266c | ||
|
|
55fc2b2bc8 | ||
|
|
6e4ef9a099 | ||
|
|
ebf7d1e3a1 | ||
|
|
998162bc48 | ||
|
|
4fadc54b4e | ||
|
|
f4ac9a8292 | ||
|
|
7c8a7606b7 | ||
|
|
225217330b | ||
|
|
589c04a530 | ||
|
|
aa538a3a51 | ||
|
|
817e108ff5 | ||
|
|
33542d0c54 |
11
.env.example
11
.env.example
@@ -229,16 +229,5 @@ REDIS_URL=redis://localhost:6379
|
|||||||
# AUDIT_LOG_GET_USER_IP=0
|
# AUDIT_LOG_GET_USER_IP=0
|
||||||
|
|
||||||
|
|
||||||
# Cube.js Analytics (required for the analytics/dashboard feature)
|
|
||||||
# URL where the Cube.js instance is running (used by the Next.js app)
|
|
||||||
# CUBEJS_API_URL=http://localhost:4000
|
|
||||||
# Must match CUBEJS_API_SECRET set on the Cube.js server (docker-compose.dev.yml)
|
|
||||||
# CUBEJS_API_SECRET=changeme
|
|
||||||
# API token sent with each Cube.js request (empty is accepted when CUBEJS_DEV_MODE=true)
|
|
||||||
# CUBEJS_API_TOKEN=
|
|
||||||
# Postgres connection used by the Cube.js container (see docker-compose.dev.yml)
|
|
||||||
# The Cube.js container connects as user "formbricks" to database "hub" on this port
|
|
||||||
# POSTGRES_PORT=5432
|
|
||||||
|
|
||||||
# Lingo.dev API key for translation generation
|
# Lingo.dev API key for translation generation
|
||||||
LINGODOTDEV_API_KEY=your_api_key_here
|
LINGODOTDEV_API_KEY=your_api_key_here
|
||||||
42
.github/workflows/translation-check.yml
vendored
42
.github/workflows/translation-check.yml
vendored
@@ -6,19 +6,9 @@ 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:
|
||||||
@@ -33,30 +23,38 @@ 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:
|
||||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
filters: |
|
||||||
|
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
|
||||||
run: |
|
if: steps.changes.outputs.translations == 'true'
|
||||||
echo ""
|
run: pnpm run scan-translations
|
||||||
echo "🔍 Validating translation keys..."
|
|
||||||
echo ""
|
|
||||||
pnpm run scan-translations
|
|
||||||
|
|
||||||
- name: Summary
|
- name: Skip (no translation-related changes)
|
||||||
if: success()
|
if: steps.changes.outputs.translations != 'true'
|
||||||
run: |
|
run: echo "No translation-related files changed — skipping validation."
|
||||||
echo ""
|
|
||||||
echo "✅ Translation validation completed successfully!"
|
|
||||||
echo ""
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
|
|
||||||
prettier --write ./branch.json
|
|
||||||
@@ -1,40 +1 @@
|
|||||||
# Load environment variables from .env files
|
pnpm lint-staged
|
||||||
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
|
|
||||||
@@ -10,25 +10,20 @@
|
|||||||
"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.0",
|
"@chromatic-com/storybook": "^5.0.1",
|
||||||
"@storybook/addon-a11y": "10.1.11",
|
"@storybook/addon-a11y": "10.2.15",
|
||||||
"@storybook/addon-links": "10.1.11",
|
"@storybook/addon-links": "10.2.15",
|
||||||
"@storybook/addon-onboarding": "10.1.11",
|
"@storybook/addon-onboarding": "10.2.15",
|
||||||
"@storybook/react-vite": "10.1.11",
|
"@storybook/react-vite": "10.2.15",
|
||||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||||
"@tailwindcss/vite": "4.1.18",
|
"@tailwindcss/vite": "4.2.1",
|
||||||
"@typescript-eslint/parser": "8.53.0",
|
"@typescript-eslint/parser": "8.56.1",
|
||||||
"@vitejs/plugin-react": "5.1.2",
|
"@vitejs/plugin-react": "5.1.4",
|
||||||
"esbuild": "0.25.12",
|
|
||||||
"eslint-plugin-react-refresh": "0.4.26",
|
"eslint-plugin-react-refresh": "0.4.26",
|
||||||
"eslint-plugin-storybook": "10.1.11",
|
"eslint-plugin-storybook": "10.2.14",
|
||||||
"prop-types": "15.8.1",
|
"storybook": "10.2.15",
|
||||||
"storybook": "10.1.11",
|
|
||||||
"vite": "7.3.1",
|
"vite": "7.3.1",
|
||||||
"@storybook/addon-docs": "10.1.11"
|
"@storybook/addon-docs": "10.2.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
apps/web/.prettierrc.js
Normal file
6
apps/web/.prettierrc.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const baseConfig = require("../../.prettierrc.js");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...baseConfig,
|
||||||
|
tailwindConfig: "./tailwind.config.js",
|
||||||
|
};
|
||||||
@@ -101,6 +101,9 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -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="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
|
<span className="absolute inline-flex h-full w-full animate-ping-slow 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">
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-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}`}>
|
||||||
|
|||||||
@@ -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 top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-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`}>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"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"
|
"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"
|
||||||
)}>
|
)}>
|
||||||
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
||||||
|
|
||||||
|
|||||||
@@ -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 top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
@@ -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 top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ export const ProjectSettings = ({
|
|||||||
</FormProvider>
|
</FormProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<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">
|
||||||
{logoUrl && (
|
{logoUrl && (
|
||||||
<Image
|
<Image
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
@@ -239,18 +239,16 @@ 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>
|
||||||
<div className="z-0 h-3/4 w-3/4">
|
<SurveyInline
|
||||||
<SurveyInline
|
appUrl={publicDomain}
|
||||||
appUrl={publicDomain}
|
isPreviewMode={true}
|
||||||
isPreviewMode={true}
|
survey={previewSurvey(projectName || t("common.my_product"), t)}
|
||||||
survey={previewSurvey(projectName || "my Product", t)}
|
styling={previewStyling}
|
||||||
styling={previewStyling}
|
isBrandingEnabled={false}
|
||||||
isBrandingEnabled={false}
|
languageCode="default"
|
||||||
languageCode="default"
|
onFileUpload={async (file) => file.name}
|
||||||
onFileUpload={async (file) => file.name}
|
autoFocus={false}
|
||||||
autoFocus={false}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<CreateTeamModal
|
<CreateTeamModal
|
||||||
open={createTeamModalOpen}
|
open={createTeamModalOpen}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
/>
|
/>
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const ZOrganizationTeam = z.object({
|
export const ZOrganizationTeam = z.object({
|
||||||
id: z.string().cuid2(),
|
id: z.cuid2(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const ZCreateProjectAction = z.object({
|
|||||||
data: ZProjectUpdateInput,
|
data: ZProjectUpdateInput,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
|
export const createProjectAction = authenticatedActionClient.inputSchema(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
|
||||||
.schema(ZGetOrganizationsForSwitcherAction)
|
.inputSchema(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
|
||||||
.schema(ZGetProjectsForSwitcherAction)
|
.inputSchema(ZGetProjectsForSwitcherAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { ChartsListSkeleton } from "@/modules/ee/analysis/charts/components/charts-list-skeleton";
|
|
||||||
|
|
||||||
export default function ChartsListLoading() {
|
|
||||||
return <ChartsListSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { ChartsListPage as default } from "@/modules/ee/analysis/charts/pages/charts-list-page";
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
|
|
||||||
|
|
||||||
export default DashboardsListPage;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { AnalysisLayoutClient } from "@/modules/ee/analysis/components/analysis-layout-client";
|
|
||||||
|
|
||||||
export default function AnalysisLayout({
|
|
||||||
children,
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
params: Promise<{ environmentId: string }>;
|
|
||||||
}) {
|
|
||||||
return <AnalysisLayoutClient params={params}>{children}</AnalysisLayoutClient>;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
|
|
||||||
|
|
||||||
export default DashboardDetailPage;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default async function AnalysisPage({ params }: { params: Promise<{ environmentId: string }> }) {
|
|
||||||
const { environmentId } = await params;
|
|
||||||
if (!environmentId || environmentId === "undefined") {
|
|
||||||
redirect("/");
|
|
||||||
}
|
|
||||||
redirect(`/environments/${environmentId}/analysis/dashboards`);
|
|
||||||
}
|
|
||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
MessageCircle,
|
MessageCircle,
|
||||||
PanelLeftCloseIcon,
|
PanelLeftCloseIcon,
|
||||||
PanelLeftOpenIcon,
|
PanelLeftOpenIcon,
|
||||||
PieChart,
|
|
||||||
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";
|
||||||
@@ -106,13 +106,6 @@ export const MainNavigation = ({
|
|||||||
isActive: pathname?.includes("/surveys"),
|
isActive: pathname?.includes("/surveys"),
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: t("common.analysis"),
|
|
||||||
href: `/environments/${environment.id}/analysis`,
|
|
||||||
icon: PieChart,
|
|
||||||
isActive: pathname?.includes("/analysis"),
|
|
||||||
isHidden: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: `/environments/${environment.id}/contacts`,
|
href: `/environments/${environment.id}/contacts`,
|
||||||
name: t("common.contacts"),
|
name: t("common.contacts"),
|
||||||
@@ -122,6 +115,13 @@ export const MainNavigation = ({
|
|||||||
pathname?.includes("/segments") ||
|
pathname?.includes("/segments") ||
|
||||||
pathname?.includes("/attributes"),
|
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"),
|
||||||
href: `/environments/${environment.id}/workspace/general`,
|
href: `/environments/${environment.id}/workspace/general`,
|
||||||
@@ -129,7 +129,7 @@ export const MainNavigation = ({
|
|||||||
isActive: pathname?.includes("/project"),
|
isActive: pathname?.includes("/project"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, environment.id, pathname]
|
[t, environment.id, pathname, isFormbricksCloud]
|
||||||
);
|
);
|
||||||
|
|
||||||
const dropdownNavigation = [
|
const dropdownNavigation = [
|
||||||
|
|||||||
@@ -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-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
<p className="w-2/3 text-balance text-sm 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 />
|
||||||
|
|||||||
@@ -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.toSorted((a, b) => a.name.localeCompare(b.name));
|
const sorted = [...result.data].sort((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
|
||||||
|
|||||||
@@ -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.toSorted((a, b) => a.name.localeCompare(b.name));
|
const sorted = [...result.data].sort((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
|
||||||
|
|||||||
@@ -11,12 +11,6 @@ const EnvLayout = async (props: {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { environmentId } = params;
|
|
||||||
|
|
||||||
if (!environmentId || environmentId === "undefined") {
|
|
||||||
return redirect("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
// Check session first (required for userId)
|
// Check session first (required for userId)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const ZUpdateNotificationSettingsAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const updateNotificationSettingsAction = authenticatedActionClient
|
export const updateNotificationSettingsAction = authenticatedActionClient
|
||||||
.schema(ZUpdateNotificationSettingsAction)
|
.inputSchema(ZUpdateNotificationSettingsAction)
|
||||||
.action(
|
.action(
|
||||||
withAuditLogging(
|
withAuditLogging(
|
||||||
"updated",
|
"updated",
|
||||||
|
|||||||
@@ -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,8 +49,11 @@ export const NotificationSwitch = ({
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
|
updatedNotificationSettings[notificationType] = {
|
||||||
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
|
...updatedNotificationSettings[notificationType],
|
||||||
|
[surveyOrProjectOrOrganizationId]:
|
||||||
|
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
||||||
@@ -78,7 +81,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(
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ async function handleEmailUpdate({
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
|
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
|
||||||
withAuditLogging(
|
withAuditLogging(
|
||||||
"updated",
|
"updated",
|
||||||
"user",
|
"user",
|
||||||
|
|||||||
@@ -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 } from "@/lib/i18n/utils";
|
import { appLanguages, sortedAppLanguages } 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,41 +198,54 @@ export const EditProfileDetailsForm = ({
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="locale"
|
name="locale"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem className="mt-4">
|
const selectedLanguage = appLanguages.find((l) => l.code === field.value);
|
||||||
<FormLabel>{t("common.language")}</FormLabel>
|
|
||||||
<FormControl>
|
return (
|
||||||
<DropdownMenu>
|
<FormItem className="mt-4">
|
||||||
<DropdownMenuTrigger asChild>
|
<FormLabel>{t("common.language")}</FormLabel>
|
||||||
<Button
|
<FormControl>
|
||||||
type="button"
|
<DropdownMenu>
|
||||||
variant="ghost"
|
<DropdownMenuTrigger asChild>
|
||||||
className="h-10 w-full border border-slate-300 px-3 text-left">
|
<Button
|
||||||
<div className="flex w-full items-center justify-between">
|
type="button"
|
||||||
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
|
variant="ghost"
|
||||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
className="h-10 w-full border border-slate-300 px-3 text-left">
|
||||||
</div>
|
<div className="flex w-full items-center justify-between">
|
||||||
</Button>
|
{selectedLanguage ? (
|
||||||
</DropdownMenuTrigger>
|
<>
|
||||||
<DropdownMenuContent
|
{selectedLanguage.label["en-US"]}
|
||||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] &&
|
||||||
align="start">
|
` (${selectedLanguage.label.native})`}
|
||||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
</>
|
||||||
{appLanguages.map((lang) => (
|
) : (
|
||||||
<DropdownMenuRadioItem
|
t("common.select")
|
||||||
key={lang.code}
|
)}
|
||||||
value={lang.code}
|
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||||
className="min-h-8 cursor-pointer">
|
</div>
|
||||||
{lang.label["en-US"]}
|
</Button>
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuTrigger>
|
||||||
))}
|
<DropdownMenuContent
|
||||||
</DropdownMenuRadioGroup>
|
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
||||||
</DropdownMenuContent>
|
align="start">
|
||||||
</DropdownMenu>
|
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||||
</FormControl>
|
{sortedAppLanguages.map((lang) => (
|
||||||
<FormError />
|
<DropdownMenuRadioItem
|
||||||
</FormItem>
|
key={lang.code}
|
||||||
)}
|
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 && (
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
|
|||||||
aria-label="password"
|
aria-label="password"
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
required
|
required
|
||||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={(password) => field.onChange(password)}
|
onChange={(password) => field.onChange(password)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const ZUpdateOrganizationNameAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const updateOrganizationNameAction = authenticatedActionClient
|
export const updateOrganizationNameAction = authenticatedActionClient
|
||||||
.schema(ZUpdateOrganizationNameAction)
|
.inputSchema(ZUpdateOrganizationNameAction)
|
||||||
.action(
|
.action(
|
||||||
withAuditLogging(
|
withAuditLogging(
|
||||||
"updated",
|
"updated",
|
||||||
@@ -55,28 +55,36 @@ const ZDeleteOrganizationAction = z.object({
|
|||||||
organizationId: ZId,
|
organizationId: ZId,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
|
export const deleteOrganizationAction = authenticatedActionClient
|
||||||
withAuditLogging(
|
.inputSchema(ZDeleteOrganizationAction)
|
||||||
"deleted",
|
.action(
|
||||||
"organization",
|
withAuditLogging(
|
||||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
"deleted",
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
"organization",
|
||||||
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
async ({
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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";
|
||||||
@@ -81,7 +82,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
<div className="space-y-2">
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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";
|
||||||
@@ -22,7 +23,7 @@ const ZGetResponsesAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getResponsesAction = authenticatedActionClient
|
export const getResponsesAction = authenticatedActionClient
|
||||||
.schema(ZGetResponsesAction)
|
.inputSchema(ZGetResponsesAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -56,7 +57,7 @@ const ZGetSurveySummaryAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getSurveySummaryAction = authenticatedActionClient
|
export const getSurveySummaryAction = authenticatedActionClient
|
||||||
.schema(ZGetSurveySummaryAction)
|
.inputSchema(ZGetSurveySummaryAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -84,7 +85,7 @@ const ZGetResponseCountAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getResponseCountAction = authenticatedActionClient
|
export const getResponseCountAction = authenticatedActionClient
|
||||||
.schema(ZGetResponseCountAction)
|
.inputSchema(ZGetResponseCountAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -106,3 +107,31 @@ 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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 = {
|
||||||
@@ -14,10 +15,11 @@ 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: `${responseCount} Responses | ${survey?.name} Results`,
|
title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||||
.schema(ZSendEmbedSurveyPreviewEmailAction)
|
.inputSchema(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.schema(ZResetSurveyAction).action(
|
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
|
||||||
withAuditLogging(
|
withAuditLogging(
|
||||||
"updated",
|
"updated",
|
||||||
"survey",
|
"survey",
|
||||||
@@ -123,7 +123,7 @@ const ZGetEmailHtmlAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getEmailHtmlAction = authenticatedActionClient
|
export const getEmailHtmlAction = authenticatedActionClient
|
||||||
.schema(ZGetEmailHtmlAction)
|
.inputSchema(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
|
||||||
.schema(ZGeneratePersonalLinksAction)
|
.inputSchema(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
|
||||||
.schema(ZUpdateSingleUseLinksAction)
|
.inputSchema(ZUpdateSingleUseLinksAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ 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">
|
||||||
{elementSummary.booked.count}{" "}
|
{t("common.count_responses", { 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} />
|
||||||
@@ -47,8 +46,7 @@ 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">
|
||||||
{elementSummary.skipped.count}{" "}
|
{t("common.count_responses", { 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} />
|
||||||
|
|||||||
@@ -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">
|
||||||
{summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")}
|
{t("common.count_responses", { count: summaryItem.count })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="group-hover:opacity-80">
|
<div className="group-hover:opacity-80">
|
||||||
|
|||||||
@@ -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" />
|
||||||
{`${elementSummary.responseCount} ${t("common.responses")}`}
|
{t("common.count_responses", { count: elementSummary.responseCount })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{additionalInfo}
|
{additionalInfo}
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ 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" />
|
||||||
{elementSummary.responseCount}{" "}
|
{t("common.count_responses", { count: elementSummary.responseCount })}
|
||||||
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`;
|
return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) });
|
||||||
}
|
}
|
||||||
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="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"
|
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"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilter(
|
setFilter(
|
||||||
elementSummary.element.id,
|
elementSummary.element.id,
|
||||||
|
|||||||
@@ -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" />
|
||||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
{t("common.count_selections", { count: elementSummary.selectionCount })}
|
||||||
</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">
|
||||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
{t("common.count_selections", { count: result.count })}
|
||||||
</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)}%
|
||||||
|
|||||||
@@ -123,8 +123,7 @@ 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">
|
||||||
{elementSummary[group]?.count}{" "}
|
{t("common.count_responses", { count: elementSummary[group]?.count })}
|
||||||
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@@ -158,7 +157,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="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
|
className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110"
|
||||||
style={{
|
style={{
|
||||||
height: `${Math.max(choice.percentage, 2)}%`,
|
height: `${Math.max(choice.percentage, 2)}%`,
|
||||||
opacity,
|
opacity,
|
||||||
|
|||||||
@@ -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" />
|
||||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
{t("common.count_selections", { count: elementSummary.selectionCount })}
|
||||||
</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">
|
||||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
{t("common.count_selections", { count: result.count })}
|
||||||
</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)}%
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
|||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
<div
|
<div
|
||||||
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
className={`h-full bg-brand-dark ${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">
|
||||||
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
|
{t("common.count_responses", { count: result.count })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||||
@@ -215,8 +215,7 @@ 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">
|
||||||
{elementSummary.dismissed.count}{" "}
|
{t("common.count_responses", { count: elementSummary.dismissed.count })}
|
||||||
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,8 +10,8 @@ interface SummaryMetadataProps {
|
|||||||
surveySummary: TSurveySummary["meta"];
|
surveySummary: TSurveySummary["meta"];
|
||||||
quotasCount: number;
|
quotasCount: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
tab: "dropOffs" | "quotas" | undefined;
|
tab: "dropOffs" | "quotas" | "impressions" | undefined;
|
||||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
|
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | 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") => {
|
const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => {
|
||||||
const change = tab === val ? undefined : val;
|
const change = tab === val ? undefined : val;
|
||||||
setTab(change);
|
setTab(change);
|
||||||
};
|
};
|
||||||
@@ -65,12 +65,16 @@ 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"
|
||||||
)}>
|
)}>
|
||||||
<StatCard
|
<InteractiveCard
|
||||||
|
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")}
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, 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 { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
import {
|
||||||
|
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,
|
||||||
@@ -51,17 +61,76 @@ 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" | undefined>(undefined);
|
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | 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
|
||||||
@@ -121,6 +190,18 @@ 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">
|
||||||
|
|||||||
@@ -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";
|
tab: "dropOffs" | "quotas" | "impressions";
|
||||||
label: string;
|
label: string;
|
||||||
percentage: number;
|
percentage: number | null;
|
||||||
value: React.ReactNode;
|
value: React.ReactNode;
|
||||||
tooltipText: string;
|
tooltipText: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|||||||
@@ -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-question",
|
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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="font-mono text-xs whitespace-pre-wrap text-slate-600">
|
<pre className="whitespace-pre-wrap font-mono text-xs 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(
|
||||||
"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"
|
"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"
|
||||||
)}
|
)}
|
||||||
{...field}
|
{...field}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
|
|||||||
@@ -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 top-3 right-3" text={t("common.new")} />
|
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={`/environments/${environmentId}/settings/notifications`}
|
href={`/environments/${environmentId}/settings/notifications`}
|
||||||
|
|||||||
@@ -1095,7 +1095,7 @@ export const getResponsesForSummary = reactCache(
|
|||||||
[limit, ZOptionalNumber],
|
[limit, ZOptionalNumber],
|
||||||
[offset, ZOptionalNumber],
|
[offset, ZOptionalNumber],
|
||||||
[filterCriteria, ZResponseFilterCriteria.optional()],
|
[filterCriteria, ZResponseFilterCriteria.optional()],
|
||||||
[cursor, z.string().cuid2().optional()]
|
[cursor, z.cuid2().optional()]
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryLimit = limit ?? RESPONSES_PER_PAGE;
|
const queryLimit = limit ?? RESPONSES_PER_PAGE;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const ZGetResponsesDownloadUrlAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getResponsesDownloadUrlAction = authenticatedActionClient
|
export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||||
.schema(ZGetResponsesDownloadUrlAction)
|
.inputSchema(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
|
||||||
.schema(ZGetSurveyFilterDataAction)
|
.inputSchema(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.schema(ZSurvey).action(
|
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
|
||||||
withAuditLogging(
|
withAuditLogging(
|
||||||
"updated",
|
"updated",
|
||||||
"survey",
|
"survey",
|
||||||
|
|||||||
@@ -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 ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
|
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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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}>
|
||||||
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
{t("common.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">and</p>
|
<p className="mr-4 font-semibold text-slate-800">{t("common.and")}</p>
|
||||||
<hr className="w-full text-slate-600" />
|
<hr className="w-full text-slate-600" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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;
|
||||||
@@ -21,7 +21,7 @@ const ZCreateOrUpdateIntegrationAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const createOrUpdateIntegrationAction = authenticatedActionClient
|
export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||||
.schema(ZCreateOrUpdateIntegrationAction)
|
.inputSchema(ZCreateOrUpdateIntegrationAction)
|
||||||
.action(
|
.action(
|
||||||
withAuditLogging(
|
withAuditLogging(
|
||||||
"createdUpdated",
|
"createdUpdated",
|
||||||
@@ -67,7 +67,7 @@ const ZDeleteIntegrationAction = z.object({
|
|||||||
integrationId: ZId,
|
integrationId: ZId,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action(
|
export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDeleteIntegrationAction).action(
|
||||||
withAuditLogging(
|
withAuditLogging(
|
||||||
"deleted",
|
"deleted",
|
||||||
"integration",
|
"integration",
|
||||||
|
|||||||
@@ -1,12 +1,49 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
|
import {
|
||||||
|
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(),
|
||||||
@@ -14,7 +51,7 @@ const ZGetSpreadsheetNameByIdAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getSpreadsheetNameByIdAction = authenticatedActionClient
|
export const getSpreadsheetNameByIdAction = authenticatedActionClient
|
||||||
.schema(ZGetSpreadsheetNameByIdAction)
|
.inputSchema(ZGetSpreadsheetNameByIdAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ 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";
|
||||||
@@ -118,6 +122,17 @@ 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)) {
|
||||||
@@ -129,6 +144,7 @@ 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,
|
||||||
@@ -137,13 +153,11 @@ export const AddIntegrationModal = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!spreadsheetNameResponse?.data) {
|
if (!spreadsheetNameResponse?.data) {
|
||||||
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
|
showErrorMessageToast(spreadsheetNameResponse);
|
||||||
throw new Error(errorMessage);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import {
|
import {
|
||||||
TIntegrationGoogleSheets,
|
TIntegrationGoogleSheets,
|
||||||
@@ -8,9 +8,11 @@ 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";
|
||||||
|
|
||||||
@@ -35,10 +37,23 @@ 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) {
|
||||||
@@ -64,6 +79,8 @@ export const GoogleSheetWrapper = ({
|
|||||||
setOpenAddIntegrationModal={setIsModalOpen}
|
setOpenAddIntegrationModal={setIsModalOpen}
|
||||||
setIsConnected={setIsConnected}
|
setIsConnected={setIsConnected}
|
||||||
setSelectedIntegration={setSelectedIntegration}
|
setSelectedIntegration={setSelectedIntegration}
|
||||||
|
showReconnectButton={showReconnectButton}
|
||||||
|
handleGoogleAuthorization={handleGoogleAuthorization}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Trash2Icon } from "lucide-react";
|
import { RefreshCcwIcon, 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,15 +12,19 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +33,8 @@ export const ManageIntegration = ({
|
|||||||
setOpenAddIntegrationModal,
|
setOpenAddIntegrationModal,
|
||||||
setIsConnected,
|
setIsConnected,
|
||||||
setSelectedIntegration,
|
setSelectedIntegration,
|
||||||
|
showReconnectButton,
|
||||||
|
handleGoogleAuthorization,
|
||||||
locale,
|
locale,
|
||||||
}: ManageIntegrationProps) => {
|
}: ManageIntegrationProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -68,7 +74,17 @@ 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">
|
||||||
<div className="flex w-full justify-end">
|
{showReconnectButton && (
|
||||||
|
<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">
|
||||||
@@ -77,6 +93,19 @@ 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);
|
||||||
|
|||||||
@@ -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 bg-slate-200 select-none">
|
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||||
{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 text-center text-sm whitespace-nowrap text-slate-500">
|
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm 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>
|
||||||
|
|||||||
@@ -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 bg-slate-200 select-none">
|
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||||
{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 text-center text-sm whitespace-nowrap text-slate-500">
|
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm 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>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const ZGetSlackChannelsAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getSlackChannelsAction = authenticatedActionClient
|
export const getSlackChannelsAction = authenticatedActionClient
|
||||||
.schema(ZGetSlackChannelsAction)
|
.inputSchema(ZGetSlackChannelsAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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[] = [];
|
||||||
@@ -256,10 +257,16 @@ 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) => choice.imageUrl)
|
.map((choice) => resolveStorageUrlAuto(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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -368,7 +375,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) => choice.imageUrl);
|
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ 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";
|
||||||
@@ -95,12 +97,15 @@ 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,
|
||||||
@@ -131,13 +136,17 @@ export const POST = async (request: Request) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetchWithTimeout(webhook.url, {
|
return validateWebhookUrl(webhook.url)
|
||||||
method: "POST",
|
.then(() =>
|
||||||
headers: requestHeaders,
|
fetchWithTimeout(webhook.url, {
|
||||||
body,
|
method: "POST",
|
||||||
}).catch((error) => {
|
headers: requestHeaders,
|
||||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
body,
|
||||||
});
|
})
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (event === "responseFinished") {
|
if (event === "responseFinished") {
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import cubejs, { Query } from "@cubejs-client/core";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cube.js client for executing analytics queries.
|
|
||||||
*
|
|
||||||
* Authentication is handled via the CUBEJS_API_SECRET env var which must match
|
|
||||||
* the secret configured on the Cube.js server. In development an empty token is
|
|
||||||
* accepted when the Cube.js instance runs in dev mode.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const getApiUrl = () => {
|
|
||||||
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
|
|
||||||
if (baseUrl.includes("/cubejs-api/v1")) {
|
|
||||||
return baseUrl;
|
|
||||||
}
|
|
||||||
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const API_URL = getApiUrl();
|
|
||||||
|
|
||||||
export function createCubeClient() {
|
|
||||||
const token = process.env.CUBEJS_API_TOKEN ?? "";
|
|
||||||
console.log(`[CubeClient] Connecting to ${API_URL} (token ${token ? "set" : "empty"})`);
|
|
||||||
return cubejs(token, {
|
|
||||||
apiUrl: API_URL,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a Cube.js query and return the table pivot data.
|
|
||||||
*/
|
|
||||||
export async function executeQuery(query: Query) {
|
|
||||||
console.log("[CubeClient] executeQuery called with:", JSON.stringify(query, null, 2));
|
|
||||||
const client = createCubeClient();
|
|
||||||
try {
|
|
||||||
const resultSet = await client.load(query);
|
|
||||||
const rows = resultSet.tablePivot();
|
|
||||||
console.log(`[CubeClient] Query succeeded — ${rows.length} row(s) returned`);
|
|
||||||
return rows;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[CubeClient] Query failed:", {
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
apiUrl: API_URL,
|
|
||||||
query,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the Cube.js schema file to extract measures and dimensions
|
|
||||||
* This keeps the schema as the single source of truth for AI query generation
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface MeasureInfo {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DimensionInfo {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
type: "string" | "number" | "time";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path to schema file - self-contained within the analytics folder
|
|
||||||
const SCHEMA_FILE_PATH = path.join(process.cwd(), "app", "api", "analytics", "_schema", "FeedbackRecords.js");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract description from a schema property object
|
|
||||||
*/
|
|
||||||
function extractDescription(objStr: string): string {
|
|
||||||
const descMatch = objStr.match(/description:\s*`([^`]+)`/);
|
|
||||||
return descMatch ? descMatch[1] : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract type from a dimension object
|
|
||||||
*/
|
|
||||||
function extractType(objStr: string): "string" | "number" | "time" {
|
|
||||||
const typeMatch = objStr.match(/type:\s*`(string|number|time)`/);
|
|
||||||
return (typeMatch?.[1] as "string" | "number" | "time") || "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to extract content inside the first matching brace block
|
|
||||||
*/
|
|
||||||
function extractInnerBlockContent(content: string, startRegex: RegExp): string | null {
|
|
||||||
const match = content.match(startRegex);
|
|
||||||
if (!match) return null;
|
|
||||||
|
|
||||||
// Backtrack to find the opening brace in the match
|
|
||||||
const braceIndex = match[0].lastIndexOf("{");
|
|
||||||
if (braceIndex === -1) return null; // Should not happen given regex usage
|
|
||||||
|
|
||||||
// Actually we can just start scanning from the end of the match if the regex ends with {
|
|
||||||
// But let's be safer: start counting from the opening brace.
|
|
||||||
const absoluteStartIndex = match.index! + braceIndex;
|
|
||||||
|
|
||||||
let braceCount = 1;
|
|
||||||
let i = absoluteStartIndex + 1;
|
|
||||||
|
|
||||||
while (braceCount > 0 && i < content.length) {
|
|
||||||
if (content[i] === "{") braceCount++;
|
|
||||||
else if (content[i] === "}") braceCount--;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (braceCount === 0) {
|
|
||||||
return content.substring(absoluteStartIndex + 1, i - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse measures from the schema file
|
|
||||||
*/
|
|
||||||
function parseMeasures(schemaContent: string): MeasureInfo[] {
|
|
||||||
const measures: MeasureInfo[] = [];
|
|
||||||
|
|
||||||
const measuresBlock = extractInnerBlockContent(schemaContent, /measures:\s*\{/);
|
|
||||||
if (!measuresBlock) return measures;
|
|
||||||
|
|
||||||
// Match each measure: measureName: { ... }
|
|
||||||
const measureRegex = /(\w+):\s*\{/g;
|
|
||||||
let match;
|
|
||||||
|
|
||||||
while ((match = measureRegex.exec(measuresBlock)) !== null) {
|
|
||||||
const name = match[1];
|
|
||||||
const startIndex = match.index + match[0].length;
|
|
||||||
|
|
||||||
// Find the matching closing brace
|
|
||||||
let braceCount = 1;
|
|
||||||
let endIndex = startIndex;
|
|
||||||
|
|
||||||
while (braceCount > 0 && endIndex < measuresBlock.length) {
|
|
||||||
if (measuresBlock[endIndex] === "{") braceCount++;
|
|
||||||
if (measuresBlock[endIndex] === "}") braceCount--;
|
|
||||||
endIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = measuresBlock.substring(startIndex, endIndex - 1);
|
|
||||||
const description = extractDescription(body);
|
|
||||||
|
|
||||||
if (description) {
|
|
||||||
measures.push({ name, description });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return measures;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse dimensions from a specific cube
|
|
||||||
*/
|
|
||||||
function parseDimensionsFromCube(cubeContent: string, cubeName: string): DimensionInfo[] {
|
|
||||||
const dimensions: DimensionInfo[] = [];
|
|
||||||
|
|
||||||
const dimensionsBlock = extractInnerBlockContent(cubeContent, /dimensions:\s*\{/);
|
|
||||||
if (!dimensionsBlock) return dimensions;
|
|
||||||
|
|
||||||
// Match each dimension: dimensionName: { ... }
|
|
||||||
const dimensionRegex = /(\w+):\s*\{/g;
|
|
||||||
let match;
|
|
||||||
|
|
||||||
while ((match = dimensionRegex.exec(dimensionsBlock)) !== null) {
|
|
||||||
const name = match[1];
|
|
||||||
const startIndex = match.index + match[0].length;
|
|
||||||
|
|
||||||
// Find the matching closing brace
|
|
||||||
let braceCount = 1;
|
|
||||||
let endIndex = startIndex;
|
|
||||||
|
|
||||||
while (braceCount > 0 && endIndex < dimensionsBlock.length) {
|
|
||||||
if (dimensionsBlock[endIndex] === "{") braceCount++;
|
|
||||||
if (dimensionsBlock[endIndex] === "}") braceCount--;
|
|
||||||
endIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = dimensionsBlock.substring(startIndex, endIndex - 1);
|
|
||||||
const description = extractDescription(body);
|
|
||||||
const type = extractType(body);
|
|
||||||
|
|
||||||
// Skip primaryKey dimensions (like 'id') and internal dimensions
|
|
||||||
if (body.includes("primaryKey: true") || name === "feedbackRecordId") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (description) {
|
|
||||||
dimensions.push({
|
|
||||||
name: cubeName === "FeedbackRecords" ? name : `${cubeName}.${name}`,
|
|
||||||
description,
|
|
||||||
type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dimensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse dimensions from the schema file
|
|
||||||
*/
|
|
||||||
function parseDimensions(schemaContent: string): DimensionInfo[] {
|
|
||||||
const dimensions: DimensionInfo[] = [];
|
|
||||||
|
|
||||||
// Extract dimensions from FeedbackRecords cube
|
|
||||||
const feedbackRecordsMatch = schemaContent.match(/cube\(`FeedbackRecords`,\s*\{([\s\S]*?)\n\}\);/);
|
|
||||||
if (feedbackRecordsMatch) {
|
|
||||||
const feedbackRecordsDimensions = parseDimensionsFromCube(feedbackRecordsMatch[1], "FeedbackRecords");
|
|
||||||
dimensions.push(...feedbackRecordsDimensions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract dimensions from TopicsUnnested cube
|
|
||||||
const topicsUnnestedMatch = schemaContent.match(/cube\(`TopicsUnnested`,\s*\{([\s\S]*?)\n\}\);/);
|
|
||||||
if (topicsUnnestedMatch) {
|
|
||||||
const topicsDimensions = parseDimensionsFromCube(topicsUnnestedMatch[1], "TopicsUnnested");
|
|
||||||
dimensions.push(...topicsDimensions);
|
|
||||||
}
|
|
||||||
|
|
||||||
return dimensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read and parse the schema file
|
|
||||||
*/
|
|
||||||
export function parseSchemaFile(): {
|
|
||||||
measures: MeasureInfo[];
|
|
||||||
dimensions: DimensionInfo[];
|
|
||||||
} {
|
|
||||||
try {
|
|
||||||
const schemaContent = fs.readFileSync(SCHEMA_FILE_PATH, "utf-8");
|
|
||||||
const measures = parseMeasures(schemaContent);
|
|
||||||
const dimensions = parseDimensions(schemaContent);
|
|
||||||
|
|
||||||
return { measures, dimensions };
|
|
||||||
} catch {
|
|
||||||
return { measures: [], dimensions: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate the schema context string for AI query generation
|
|
||||||
*/
|
|
||||||
export function generateSchemaContext(): string {
|
|
||||||
const { measures, dimensions } = parseSchemaFile();
|
|
||||||
const CUBE_NAME = "FeedbackRecords";
|
|
||||||
|
|
||||||
const measuresList = measures.map((m) => `- ${CUBE_NAME}.${m.name} - ${m.description}`).join("\n");
|
|
||||||
|
|
||||||
const dimensionsList = dimensions
|
|
||||||
.map((d) => {
|
|
||||||
const typeLabel = d.type === "time" ? " (time dimension)" : ` (${d.type})`;
|
|
||||||
// Dimensions from TopicsUnnested already have the cube prefix
|
|
||||||
const fullName = d.name.includes(".") ? d.name : `${CUBE_NAME}.${d.name}`;
|
|
||||||
return `- ${fullName} - ${d.description}${typeLabel}`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const categoricalDimensions = dimensions
|
|
||||||
.filter(
|
|
||||||
(d) =>
|
|
||||||
d.type === "string" &&
|
|
||||||
!d.name.includes("responseId") &&
|
|
||||||
!d.name.includes("userIdentifier") &&
|
|
||||||
!d.name.includes("feedbackRecordId")
|
|
||||||
)
|
|
||||||
.map((d) => (d.name.includes(".") ? d.name : `${CUBE_NAME}.${d.name}`))
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
return `
|
|
||||||
You are a CubeJS query generator. Your task is to convert natural language requests into valid CubeJS query JSON objects.
|
|
||||||
|
|
||||||
Available Cubes: ${CUBE_NAME}, TopicsUnnested
|
|
||||||
|
|
||||||
MEASURES (use these in the "measures" array):
|
|
||||||
${measuresList}
|
|
||||||
|
|
||||||
DIMENSIONS (use these in the "dimensions" array):
|
|
||||||
${dimensionsList}
|
|
||||||
|
|
||||||
TIME DIMENSIONS:
|
|
||||||
- ${CUBE_NAME}.collectedAt can be used with granularity: 'day', 'week', 'month', 'year'
|
|
||||||
- Use "timeDimensions" array for time-based queries with dateRange like "last 7 days", "last 30 days", "this month", etc.
|
|
||||||
|
|
||||||
CHART TYPE SUGGESTIONS:
|
|
||||||
- If query has timeDimensions → suggest "bar" or "line"
|
|
||||||
- If query has categorical dimensions (${categoricalDimensions}) → suggest "donut" or "bar"
|
|
||||||
- If query has only measures → suggest "kpi"
|
|
||||||
- If query compares multiple measures → suggest "bar"
|
|
||||||
|
|
||||||
FILTERS:
|
|
||||||
- Use "filters" array to include/exclude records based on dimension values
|
|
||||||
- Filter format: { "member": "CubeName.dimensionName", "operator": "operator" } OR { "member": "CubeName.dimensionName", "operator": "operator", "values": [...] }
|
|
||||||
- Common operators:
|
|
||||||
* "set" - dimension is not null/empty (Set "values" to null)
|
|
||||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "set", "values": null }
|
|
||||||
* "notSet" - dimension is null/empty (Set "values" to null)
|
|
||||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "notSet", "values": null }
|
|
||||||
* "equals" - exact match (REQUIRES "values" field)
|
|
||||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "equals", "values": ["happy"] }
|
|
||||||
* "notEquals" - not equal (REQUIRES "values" field)
|
|
||||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "notEquals", "values": ["sad"] }
|
|
||||||
* "contains" - contains text (REQUIRES "values" field)
|
|
||||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "contains", "values": ["happy"] }
|
|
||||||
- Examples for common user requests:
|
|
||||||
* "only records with emotion" or "for records that have emotion" → { "member": "${CUBE_NAME}.emotion", "operator": "set", "values": null }
|
|
||||||
* "exclude records without emotion" or "do not include records without emotion" → { "member": "${CUBE_NAME}.emotion", "operator": "set", "values": null }
|
|
||||||
* "exclude records with emotion" or "do not include records with emotion" → { "member": "${CUBE_NAME}.emotion", "operator": "notSet", "values": null }
|
|
||||||
* "only happy emotions" → { "member": "${CUBE_NAME}.emotion", "operator": "equals", "values": ["happy"] }
|
|
||||||
|
|
||||||
IMPORTANT RULES:
|
|
||||||
1. Always return valid JSON only, no markdown or code blocks
|
|
||||||
2. Use exact measure/dimension names as listed above
|
|
||||||
3. Include "chartType" field: "bar", "line", "donut", "kpi", or "area"
|
|
||||||
4. For time queries, use timeDimensions array with granularity and dateRange
|
|
||||||
5. Return format: { "measures": [...], "dimensions": [...], "timeDimensions": [...], "filters": [...], "chartType": "..." }
|
|
||||||
6. If user asks about trends over time, use timeDimensions
|
|
||||||
7. If user asks "by X", add X as a dimension
|
|
||||||
8. If user asks for counts or totals, use ${CUBE_NAME}.count
|
|
||||||
9. If user asks for NPS, use ${CUBE_NAME}.npsScore
|
|
||||||
10. If user asks about topics, use TopicsUnnested.topic (NOT ${CUBE_NAME}.topic)
|
|
||||||
11. CRITICAL: If user says "only records with X", "exclude records without X", or "for records that have X", add a filter with operator "set" for that dimension
|
|
||||||
12. CRITICAL: If user says "exclude records with X", "do not include records with X", or "without X", add a filter with operator "notSet" for that dimension
|
|
||||||
13. Always include filters when user explicitly mentions including/excluding records based on dimension values
|
|
||||||
`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CUBE_NAME = "FeedbackRecords";
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* TypeScript types for the Analytics API
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TimeDimension {
|
|
||||||
dimension: string;
|
|
||||||
granularity?: "hour" | "day" | "week" | "month" | "quarter" | "year";
|
|
||||||
dateRange?: string | string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Filter {
|
|
||||||
member: string;
|
|
||||||
operator:
|
|
||||||
| "equals"
|
|
||||||
| "notEquals"
|
|
||||||
| "contains"
|
|
||||||
| "notContains"
|
|
||||||
| "set"
|
|
||||||
| "notSet"
|
|
||||||
| "gt"
|
|
||||||
| "gte"
|
|
||||||
| "lt"
|
|
||||||
| "lte";
|
|
||||||
values?: string[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CubeQuery {
|
|
||||||
measures: string[];
|
|
||||||
dimensions?: string[];
|
|
||||||
timeDimensions?: TimeDimension[];
|
|
||||||
filters?: Filter[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnalyticsResponse {
|
|
||||||
query: CubeQuery;
|
|
||||||
chartType: "bar" | "line" | "donut" | "kpi" | "area" | "pie";
|
|
||||||
data?: Record<string, any>[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
cube(`FeedbackRecords`, {
|
|
||||||
sql: `SELECT * FROM feedback_records`,
|
|
||||||
|
|
||||||
measures: {
|
|
||||||
count: {
|
|
||||||
type: `count`,
|
|
||||||
description: `Total number of feedback responses`,
|
|
||||||
},
|
|
||||||
|
|
||||||
promoterCount: {
|
|
||||||
type: `count`,
|
|
||||||
filters: [{ sql: `${CUBE}.value_number >= 9` }],
|
|
||||||
description: `Number of promoters (NPS score 9-10)`,
|
|
||||||
},
|
|
||||||
|
|
||||||
detractorCount: {
|
|
||||||
type: `count`,
|
|
||||||
filters: [{ sql: `${CUBE}.value_number <= 6` }],
|
|
||||||
description: `Number of detractors (NPS score 0-6)`,
|
|
||||||
},
|
|
||||||
|
|
||||||
passiveCount: {
|
|
||||||
type: `count`,
|
|
||||||
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
|
|
||||||
description: `Number of passives (NPS score 7-8)`,
|
|
||||||
},
|
|
||||||
|
|
||||||
npsScore: {
|
|
||||||
type: `number`,
|
|
||||||
sql: `
|
|
||||||
CASE
|
|
||||||
WHEN COUNT(*) = 0 THEN 0
|
|
||||||
ELSE ROUND(
|
|
||||||
(
|
|
||||||
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
|
||||||
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
|
|
||||||
/ COUNT(*)::numeric
|
|
||||||
) * 100,
|
|
||||||
2
|
|
||||||
)
|
|
||||||
END
|
|
||||||
`,
|
|
||||||
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
|
|
||||||
},
|
|
||||||
|
|
||||||
averageScore: {
|
|
||||||
type: `avg`,
|
|
||||||
sql: `${CUBE}.value_number`,
|
|
||||||
description: `Average NPS score`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
dimensions: {
|
|
||||||
id: {
|
|
||||||
sql: `id`,
|
|
||||||
type: `string`,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
sentiment: {
|
|
||||||
sql: `sentiment`,
|
|
||||||
type: `string`,
|
|
||||||
description: `Sentiment extracted from metadata JSONB field`,
|
|
||||||
},
|
|
||||||
|
|
||||||
sourceType: {
|
|
||||||
sql: `source_type`,
|
|
||||||
type: `string`,
|
|
||||||
description: `Source type of the feedback (e.g., nps_campaign, survey)`,
|
|
||||||
},
|
|
||||||
|
|
||||||
sourceName: {
|
|
||||||
sql: `source_name`,
|
|
||||||
type: `string`,
|
|
||||||
description: `Human-readable name of the source`,
|
|
||||||
},
|
|
||||||
|
|
||||||
fieldType: {
|
|
||||||
sql: `field_type`,
|
|
||||||
type: `string`,
|
|
||||||
description: `Type of feedback field (e.g., nps, text, rating)`,
|
|
||||||
},
|
|
||||||
|
|
||||||
collectedAt: {
|
|
||||||
sql: `collected_at`,
|
|
||||||
type: `time`,
|
|
||||||
description: `Timestamp when the feedback was collected`,
|
|
||||||
},
|
|
||||||
|
|
||||||
npsValue: {
|
|
||||||
sql: `value_number`,
|
|
||||||
type: `number`,
|
|
||||||
description: `Raw NPS score value (0-10)`,
|
|
||||||
},
|
|
||||||
|
|
||||||
responseId: {
|
|
||||||
sql: `response_id`,
|
|
||||||
type: `string`,
|
|
||||||
description: `Unique identifier linking related feedback records`,
|
|
||||||
},
|
|
||||||
|
|
||||||
userIdentifier: {
|
|
||||||
sql: `user_identifier`,
|
|
||||||
type: `string`,
|
|
||||||
description: `Identifier of the user who provided feedback`,
|
|
||||||
},
|
|
||||||
|
|
||||||
emotion: {
|
|
||||||
sql: `emotion`,
|
|
||||||
type: `string`,
|
|
||||||
description: `Emotion extracted from metadata JSONB field`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
joins: {
|
|
||||||
TopicsUnnested: {
|
|
||||||
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
|
|
||||||
relationship: `hasMany`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
cube(`TopicsUnnested`, {
|
|
||||||
sql: `
|
|
||||||
SELECT
|
|
||||||
fr.id as feedback_record_id,
|
|
||||||
topic_elem.topic
|
|
||||||
FROM feedback_records fr
|
|
||||||
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
|
|
||||||
`,
|
|
||||||
|
|
||||||
measures: {
|
|
||||||
count: {
|
|
||||||
type: `count`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
dimensions: {
|
|
||||||
id: {
|
|
||||||
sql: `feedback_record_id || '-' || topic`,
|
|
||||||
type: `string`,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
feedbackRecordId: {
|
|
||||||
sql: `feedback_record_id`,
|
|
||||||
type: `string`,
|
|
||||||
},
|
|
||||||
|
|
||||||
topic: {
|
|
||||||
sql: `topic`,
|
|
||||||
type: `string`,
|
|
||||||
description: `Individual topic from the topics array`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import OpenAI from "openai";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { executeQuery } from "../_lib/cube-client";
|
|
||||||
import { CUBE_NAME, generateSchemaContext } from "../_lib/schema-parser";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
measures: z.array(z.string()).describe("List of measures to query"),
|
|
||||||
dimensions: z.array(z.string()).nullable().describe("List of dimensions to query"),
|
|
||||||
timeDimensions: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
dimension: z.string(),
|
|
||||||
granularity: z.enum(["day", "week", "month", "year"]).nullable(),
|
|
||||||
dateRange: z.string().nullable(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.nullable()
|
|
||||||
.describe("Time dimensions with granularity and date range"),
|
|
||||||
chartType: z
|
|
||||||
.enum(["bar", "line", "donut", "kpi", "area", "pie"])
|
|
||||||
.describe("Suggested chart type for visualization"),
|
|
||||||
filters: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
member: z.string(),
|
|
||||||
operator: z.enum([
|
|
||||||
"equals",
|
|
||||||
"notEquals",
|
|
||||||
"contains",
|
|
||||||
"notContains",
|
|
||||||
"set",
|
|
||||||
"notSet",
|
|
||||||
"gt",
|
|
||||||
"gte",
|
|
||||||
"lt",
|
|
||||||
"lte",
|
|
||||||
]),
|
|
||||||
values: z.array(z.string()).nullable(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.nullable()
|
|
||||||
.describe("Filters to apply to the query"),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate schema context dynamically from the schema file
|
|
||||||
const SCHEMA_CONTEXT = generateSchemaContext();
|
|
||||||
|
|
||||||
// JSON Schema for OpenAI structured outputs (manually created to avoid zod-to-json-schema dependency)
|
|
||||||
const jsonSchema = {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
measures: {
|
|
||||||
type: "array",
|
|
||||||
items: { type: "string" },
|
|
||||||
description: "List of measures to query",
|
|
||||||
},
|
|
||||||
dimensions: {
|
|
||||||
anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }],
|
|
||||||
description: "List of dimensions to query",
|
|
||||||
},
|
|
||||||
timeDimensions: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: "array",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
dimension: { type: "string" },
|
|
||||||
granularity: {
|
|
||||||
anyOf: [{ type: "string", enum: ["day", "week", "month", "year"] }, { type: "null" }],
|
|
||||||
},
|
|
||||||
dateRange: {
|
|
||||||
anyOf: [{ type: "string" }, { type: "null" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["dimension", "granularity", "dateRange"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "null" },
|
|
||||||
],
|
|
||||||
description: "Time dimensions with granularity and date range",
|
|
||||||
},
|
|
||||||
chartType: {
|
|
||||||
type: "string",
|
|
||||||
enum: ["bar", "line", "donut", "kpi", "area", "pie"],
|
|
||||||
description: "Suggested chart type for visualization",
|
|
||||||
},
|
|
||||||
filters: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: "array",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
member: { type: "string" },
|
|
||||||
operator: {
|
|
||||||
type: "string",
|
|
||||||
enum: [
|
|
||||||
"equals",
|
|
||||||
"notEquals",
|
|
||||||
"contains",
|
|
||||||
"notContains",
|
|
||||||
"set",
|
|
||||||
"notSet",
|
|
||||||
"gt",
|
|
||||||
"gte",
|
|
||||||
"lt",
|
|
||||||
"lte",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
values: {
|
|
||||||
anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["member", "operator", "values"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "null" },
|
|
||||||
],
|
|
||||||
description: "Filters to apply to the query",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["measures", "dimensions", "timeDimensions", "chartType", "filters"],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Initialize OpenAI client
|
|
||||||
const getOpenAIClient = () => {
|
|
||||||
if (!process.env.OPENAI_API_KEY) {
|
|
||||||
throw new Error("OPENAI_API_KEY is not configured");
|
|
||||||
}
|
|
||||||
return new OpenAI({
|
|
||||||
apiKey: process.env.OPENAI_API_KEY,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { prompt, executeQuery: shouldExecuteQuery = true } = await request.json();
|
|
||||||
|
|
||||||
if (!prompt || typeof prompt !== "string") {
|
|
||||||
return NextResponse.json({ error: "Prompt is required and must be a string" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const openai = getOpenAIClient();
|
|
||||||
|
|
||||||
// Generate Cube.js query using OpenAI structured outputs
|
|
||||||
const completion = await openai.chat.completions.create({
|
|
||||||
model: "gpt-4o-mini",
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: SCHEMA_CONTEXT },
|
|
||||||
{ role: "user", content: `User request: "${prompt}"` },
|
|
||||||
],
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "generate_cube_query",
|
|
||||||
description: "Generate a Cube.js query based on the user request",
|
|
||||||
parameters: jsonSchema,
|
|
||||||
strict: true, // Enable structured outputs
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tool_choice: { type: "function", function: { name: "generate_cube_query" } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const toolCall = completion.choices[0]?.message?.tool_calls?.[0];
|
|
||||||
if (toolCall?.function.name !== "generate_cube_query") {
|
|
||||||
throw new Error("Failed to generate structured output from OpenAI");
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = JSON.parse(toolCall.function.arguments);
|
|
||||||
|
|
||||||
// Validate with zod schema (for type safety)
|
|
||||||
const validatedQuery = schema.parse(query);
|
|
||||||
|
|
||||||
// Validate required fields (measures should minimally be present if not specified, default to count)
|
|
||||||
if (!validatedQuery.measures || validatedQuery.measures.length === 0) {
|
|
||||||
validatedQuery.measures = [`${CUBE_NAME}.count`];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract chartType (for UI purposes only, not part of CubeJS query)
|
|
||||||
const { chartType, ...cubeQuery } = validatedQuery;
|
|
||||||
|
|
||||||
// Build a clean query object, stripping null / empty arrays so Cube.js is happy
|
|
||||||
const cleanQuery: Record<string, unknown> = {
|
|
||||||
measures: cubeQuery.measures,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Array.isArray(cubeQuery.dimensions) && cubeQuery.dimensions.length > 0) {
|
|
||||||
cleanQuery.dimensions = cubeQuery.dimensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(cubeQuery.filters) && cubeQuery.filters.length > 0) {
|
|
||||||
cleanQuery.filters = cubeQuery.filters.map(
|
|
||||||
(f: { member: string; operator: string; values?: string[] | null }) => {
|
|
||||||
const cleaned: Record<string, unknown> = { member: f.member, operator: f.operator };
|
|
||||||
if (f.values !== null && f.values !== undefined) cleaned.values = f.values;
|
|
||||||
return cleaned;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(cubeQuery.timeDimensions) && cubeQuery.timeDimensions.length > 0) {
|
|
||||||
cleanQuery.timeDimensions = cubeQuery.timeDimensions.map(
|
|
||||||
(td: { dimension: string; granularity?: string | null; dateRange?: string | null }) => {
|
|
||||||
const cleaned: Record<string, unknown> = { dimension: td.dimension };
|
|
||||||
if (td.granularity !== null && td.granularity !== undefined) cleaned.granularity = td.granularity;
|
|
||||||
if (td.dateRange !== null && td.dateRange !== undefined) cleaned.dateRange = td.dateRange;
|
|
||||||
return cleaned;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute query if requested (default: true)
|
|
||||||
let data: Record<string, unknown>[] | undefined;
|
|
||||||
if (shouldExecuteQuery) {
|
|
||||||
try {
|
|
||||||
console.log("[analytics/query] Executing Cube.js query:", JSON.stringify(cleanQuery, null, 2));
|
|
||||||
data = await executeQuery(cleanQuery);
|
|
||||||
console.log(`[analytics/query] Query returned ${data?.length ?? 0} row(s)`);
|
|
||||||
} catch (queryError: unknown) {
|
|
||||||
const message = queryError instanceof Error ? queryError.message : "Unknown error";
|
|
||||||
console.error("[analytics/query] Query execution failed:", {
|
|
||||||
error: message,
|
|
||||||
stack: queryError instanceof Error ? queryError.stack : undefined,
|
|
||||||
query: cleanQuery,
|
|
||||||
cubeUrl: process.env.CUBEJS_API_URL || "http://localhost:4000 (default)",
|
|
||||||
});
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
query: cleanQuery,
|
|
||||||
chartType,
|
|
||||||
error: `Failed to execute query: ${message}`,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
query: cleanQuery,
|
|
||||||
chartType,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : "Failed to generate query";
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { google } from "googleapis";
|
import { google } from "googleapis";
|
||||||
import { getServerSession } from "next-auth";
|
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,7 +9,7 @@ import {
|
|||||||
WEBAPP_URL,
|
WEBAPP_URL,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
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";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
export const GET = async (req: Request) => {
|
export const GET = async (req: Request) => {
|
||||||
@@ -42,33 +43,39 @@ 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);
|
||||||
|
|
||||||
let key;
|
if (!code) {
|
||||||
let userEmail;
|
return Response.redirect(
|
||||||
|
`${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: "googleSheets" as "googleSheets",
|
type: integrationType,
|
||||||
environment: environmentId,
|
environment: environmentId,
|
||||||
config: {
|
config: {
|
||||||
key,
|
key,
|
||||||
data: [],
|
data: existingConfig?.data ?? [],
|
||||||
email: userEmail,
|
email: userEmail,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,14 +178,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: environmentData.project.styling,
|
styling: resolveStorageUrlsInObject(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: transformedSurveys,
|
surveys: resolveStorageUrlsInObject(transformedSurveys),
|
||||||
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
|
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const GET = withV1ApiWrapper({
|
|||||||
{
|
{
|
||||||
environmentId: params.environmentId,
|
environmentId: params.environmentId,
|
||||||
url: req.url,
|
url: req.url,
|
||||||
validationError: cuidValidation.error.errors[0]?.message,
|
validationError: cuidValidation.error.issues[0]?.message,
|
||||||
},
|
},
|
||||||
"Invalid CUID v1 format detected"
|
"Invalid CUID v1 format detected"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,13 +44,10 @@ 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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ 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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,140 +6,138 @@ 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: "100%",
|
width: "80%",
|
||||||
height: "100%",
|
height: "60%",
|
||||||
alignItems: "center",
|
backgroundColor: "white",
|
||||||
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
|
|
||||||
borderRadius: "0.75rem",
|
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
|
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||||
style={{
|
<div
|
||||||
display: "flex",
|
style={{
|
||||||
flexDirection: "column",
|
display: "flex",
|
||||||
width: "80%",
|
flexDirection: "column",
|
||||||
height: "60%",
|
width: "100%",
|
||||||
backgroundColor: "white",
|
justifyContent: "space-between",
|
||||||
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",
|
||||||
width: "100%",
|
paddingLeft: "2rem",
|
||||||
justifyContent: "space-between",
|
paddingRight: "2rem",
|
||||||
|
}}>
|
||||||
|
<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 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",
|
||||||
flexDirection: "column",
|
alignItems: "center",
|
||||||
paddingLeft: "2rem",
|
justifyContent: "center",
|
||||||
paddingRight: "2rem",
|
borderRadius: "0.75rem",
|
||||||
|
border: "1px solid transparent",
|
||||||
|
backgroundColor: brandColor ?? "#000",
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
color: "white",
|
||||||
|
height: "4.5rem",
|
||||||
|
width: "9.5rem",
|
||||||
}}>
|
}}>
|
||||||
<h2
|
Begin!
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
fontSize: "2rem",
|
|
||||||
fontWeight: "700",
|
|
||||||
letterSpacing: "-0.025em",
|
|
||||||
color: "#0f172a",
|
|
||||||
textAlign: "left",
|
|
||||||
marginTop: "3.75rem",
|
|
||||||
}}>
|
|
||||||
{name}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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
|
|
||||||
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,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} 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 { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ 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) {
|
||||||
|
|||||||
@@ -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 { validateFileUploads } from "@/modules/storage/utils";
|
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||||
|
|
||||||
async function fetchAndAuthorizeResponse(
|
async function fetchAndAuthorizeResponse(
|
||||||
@@ -57,7 +57,10 @@ export const GET = withV1ApiWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(result.response),
|
response: responses.successResponse({
|
||||||
|
...result.response,
|
||||||
|
data: resolveStorageUrlsInObject(result.response.data),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -146,7 +149,6 @@ 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
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -190,7 +192,7 @@ export const PUT = withV1ApiWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(updated),
|
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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 { validateFileUploads } from "@/modules/storage/utils";
|
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||||
import {
|
import {
|
||||||
createResponseWithQuotaEvaluation,
|
createResponseWithQuotaEvaluation,
|
||||||
getResponses,
|
getResponses,
|
||||||
@@ -54,7 +54,9 @@ export const GET = withV1ApiWrapper({
|
|||||||
allResponses.push(...environmentResponses);
|
allResponses.push(...environmentResponses);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(allResponses),
|
response: responses.successResponse(
|
||||||
|
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
|
||||||
|
),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
@@ -155,7 +157,6 @@ 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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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.string().cuid2()]);
|
validateInputs([surveyId, z.cuid2()]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deletedSurvey = await prisma.survey.delete({
|
const deletedSurvey = await prisma.survey.delete({
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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,
|
||||||
@@ -58,16 +59,18 @@ export const GET = withV1ApiWrapper({
|
|||||||
|
|
||||||
if (shouldTransformToQuestions) {
|
if (shouldTransformToQuestions) {
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse({
|
response: responses.successResponse(
|
||||||
...result.survey,
|
resolveStorageUrlsInObject({
|
||||||
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
...result.survey,
|
||||||
blocks: [],
|
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
||||||
}),
|
blocks: [],
|
||||||
|
})
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(result.survey),
|
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -202,12 +205,12 @@ export const PUT = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(surveyWithQuestions),
|
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(updatedSurvey),
|
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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({
|
||||||
@@ -55,7 +56,7 @@ export const GET = withV1ApiWrapper({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(surveysWithQuestions),
|
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ 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, ValidationError } from "@formbricks/types/errors";
|
import { DatabaseError, InvalidInputError, 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: {
|
||||||
@@ -23,6 +24,10 @@ 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();
|
||||||
@@ -75,6 +80,41 @@ 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",
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ 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();
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ describe("verifyRecaptchaToken", () => {
|
|||||||
},
|
},
|
||||||
signal: {},
|
signal: {},
|
||||||
};
|
};
|
||||||
vi.spyOn(global, "AbortController").mockImplementation(() => abortController as any);
|
vi.spyOn(global, "AbortController").mockImplementation(function AbortController() {
|
||||||
|
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);
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ 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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -131,13 +131,11 @@ 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 } = (await import(
|
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||||
"@/modules/ee/audit-logs/lib/handler"
|
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||||
)) 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 } = await import(
|
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||||
"@/app/middleware/endpoint-validator"
|
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 });
|
||||||
@@ -185,13 +183,11 @@ describe("withV1ApiWrapper", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("does not log Sentry if not 500", async () => {
|
test("does not log Sentry if not 500", async () => {
|
||||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||||
"@/modules/ee/audit-logs/lib/handler"
|
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||||
)) 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 } = await import(
|
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||||
"@/app/middleware/endpoint-validator"
|
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 });
|
||||||
@@ -233,13 +229,11 @@ describe("withV1ApiWrapper", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("logs and audits on thrown error", async () => {
|
test("logs and audits on thrown error", async () => {
|
||||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||||
"@/modules/ee/audit-logs/lib/handler"
|
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||||
)) 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 } = await import(
|
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||||
"@/app/middleware/endpoint-validator"
|
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 });
|
||||||
@@ -291,13 +285,11 @@ 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 } = (await import(
|
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||||
"@/modules/ee/audit-logs/lib/handler"
|
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||||
)) 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 } = await import(
|
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||||
"@/app/middleware/endpoint-validator"
|
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 });
|
||||||
@@ -347,13 +339,11 @@ describe("withV1ApiWrapper", () => {
|
|||||||
REDIS_URL: "redis://localhost:6379",
|
REDIS_URL: "redis://localhost:6379",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||||
"@/modules/ee/audit-logs/lib/handler"
|
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||||
)) 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 } = await import(
|
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||||
"@/app/middleware/endpoint-validator"
|
await import("@/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);
|
||||||
@@ -376,9 +366,8 @@ describe("withV1ApiWrapper", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("handles client-side API routes without authentication", async () => {
|
test("handles client-side API routes without authentication", async () => {
|
||||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||||
"@/app/middleware/endpoint-validator"
|
await import("@/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");
|
||||||
|
|
||||||
@@ -410,9 +399,8 @@ 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 } = await import(
|
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||||
"@/app/middleware/endpoint-validator"
|
await import("@/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 });
|
||||||
@@ -435,9 +423,8 @@ 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 } = await import(
|
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||||
"@/app/middleware/endpoint-validator"
|
await import("@/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);
|
||||||
@@ -462,13 +449,11 @@ describe("withV1ApiWrapper", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("skips audit log creation when no action/targetType provided", async () => {
|
test("skips audit log creation when no action/targetType provided", async () => {
|
||||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||||
"@/modules/ee/audit-logs/lib/handler"
|
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||||
)) 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 } = await import(
|
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||||
"@/app/middleware/endpoint-validator"
|
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 });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import cuid2 from "@paralleldrive/cuid2";
|
import * as 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,10 +20,6 @@ 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import cuid2 from "@paralleldrive/cuid2";
|
import { createId, isCuid } 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 = cuid2.createId();
|
const cuid = 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 (cuid2.isCuid(decryptedCuid)) {
|
if (isCuid(decryptedCuid)) {
|
||||||
return decryptedCuid;
|
return decryptedCuid;
|
||||||
} else {
|
} else {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -257,6 +257,7 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
|
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/")).toBe(false);
|
expect(isAuthProtectedRoute("/")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
|
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
|
||||||
|
expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
|
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/health")).toBe(false);
|
expect(isAuthProtectedRoute("/health")).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -313,6 +314,19 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isPublicDomainRoute("/contact/token")).toBe(false);
|
expect(isPublicDomainRoute("/contact/token")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should return true for pretty URL survey routes", () => {
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty-name-with-dashes")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/survey_id_with_underscores")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/abc123def456")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return false for malformed pretty URL survey routes", () => {
|
||||||
|
expect(isPublicDomainRoute("/p/")).toBe(false);
|
||||||
|
expect(isPublicDomainRoute("/p")).toBe(false);
|
||||||
|
expect(isPublicDomainRoute("/pretty/123")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("should return true for client API routes", () => {
|
test("should return true for client API routes", () => {
|
||||||
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
|
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true);
|
expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true);
|
||||||
@@ -375,6 +389,8 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
|
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
|
||||||
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
|
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
|
||||||
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
|
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
|
||||||
|
expect(isAdminDomainRoute("/p/pretty123")).toBe(false);
|
||||||
|
expect(isAdminDomainRoute("/p/pretty-name-with-dashes")).toBe(false);
|
||||||
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
|
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
|
||||||
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
|
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -390,6 +406,7 @@ describe("endpoint-validator", () => {
|
|||||||
test("should allow public routes on public domain", () => {
|
test("should allow public routes on public domain", () => {
|
||||||
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
|
||||||
|
expect(isRouteAllowedForDomain("/p/pretty123", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
||||||
@@ -426,6 +443,8 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
|
||||||
|
expect(isRouteAllowedForDomain("/p/pretty123", false)).toBe(false);
|
||||||
|
expect(isRouteAllowedForDomain("/p/pretty-name-with-dashes", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -440,6 +459,8 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle paths with query parameters and fragments", () => {
|
test("should handle paths with query parameters and fragments", () => {
|
||||||
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
|
||||||
|
expect(isRouteAllowedForDomain("/p/pretty123?param=value", true)).toBe(true);
|
||||||
|
expect(isRouteAllowedForDomain("/p/pretty123#section", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
|
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
|
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -450,6 +471,7 @@ describe("endpoint-validator", () => {
|
|||||||
describe("URL parsing edge cases", () => {
|
describe("URL parsing edge cases", () => {
|
||||||
test("should handle paths with query parameters", () => {
|
test("should handle paths with query parameters", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123?param=value&other=test")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
|
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
|
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
|
||||||
@@ -458,12 +480,14 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle paths with fragments", () => {
|
test("should handle paths with fragments", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
|
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123#section")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
|
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
|
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle trailing slashes", () => {
|
test("should handle trailing slashes", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123/")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
|
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
|
||||||
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
|
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
|
||||||
isManagementApi: true,
|
isManagementApi: true,
|
||||||
@@ -478,6 +502,9 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123/preview")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123/embed")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123/thank-you")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle nested client API routes", () => {
|
test("should handle nested client API routes", () => {
|
||||||
@@ -529,6 +556,7 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle special characters in survey IDs", () => {
|
test("should handle special characters in survey IDs", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
|
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty-123_test.v2")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -536,6 +564,7 @@ describe("endpoint-validator", () => {
|
|||||||
test("should properly validate malicious or injection-like URLs", () => {
|
test("should properly validate malicious or injection-like URLs", () => {
|
||||||
// SQL injection-like attempts
|
// SQL injection-like attempts
|
||||||
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
|
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
|
||||||
|
expect(isPublicDomainRoute("/p/'; DROP TABLE users; --")).toBe(true);
|
||||||
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
|
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
|
||||||
isManagementApi: true,
|
isManagementApi: true,
|
||||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||||
@@ -543,10 +572,12 @@ describe("endpoint-validator", () => {
|
|||||||
|
|
||||||
// Path traversal attempts
|
// Path traversal attempts
|
||||||
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
|
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
|
||||||
|
expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true);
|
||||||
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
|
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
|
||||||
|
|
||||||
// XSS-like attempts
|
// XSS-like attempts
|
||||||
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
|
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/<script>alert('xss')</script>")).toBe(true);
|
||||||
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
|
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
|
||||||
isClientSideApi: true,
|
isClientSideApi: true,
|
||||||
isRateLimited: true,
|
isRateLimited: true,
|
||||||
@@ -556,6 +587,7 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle URL encoding", () => {
|
test("should handle URL encoding", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
|
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true);
|
||||||
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
|
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
|
||||||
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
|
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
|
||||||
isManagementApi: true,
|
isManagementApi: true,
|
||||||
@@ -591,6 +623,7 @@ describe("endpoint-validator", () => {
|
|||||||
// These should not match due to case sensitivity
|
// These should not match due to case sensitivity
|
||||||
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
|
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
|
||||||
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
|
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
|
||||||
|
expect(isPublicDomainRoute("/P/pretty123")).toBe(false);
|
||||||
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
|
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
|
||||||
isClientSideApi: false,
|
isClientSideApi: false,
|
||||||
isRateLimited: true,
|
isRateLimited: true,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const PUBLIC_ROUTES = {
|
|||||||
SURVEY_ROUTES: [
|
SURVEY_ROUTES: [
|
||||||
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
|
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
|
||||||
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
|
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
|
||||||
|
/^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages
|
||||||
],
|
],
|
||||||
|
|
||||||
// API routes accessible from public domain
|
// API routes accessible from public domain
|
||||||
|
|||||||
@@ -14,31 +14,39 @@ const ZCreateOrganizationAction = z.object({
|
|||||||
organizationName: z.string(),
|
organizationName: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createOrganizationAction = authenticatedActionClient.schema(ZCreateOrganizationAction).action(
|
export const createOrganizationAction = authenticatedActionClient
|
||||||
withAuditLogging(
|
.inputSchema(ZCreateOrganizationAction)
|
||||||
"created",
|
.action(
|
||||||
"organization",
|
withAuditLogging(
|
||||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
"created",
|
||||||
const hasNoOrganizations = await gethasNoOrganizations();
|
"organization",
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
async ({
|
||||||
|
ctx,
|
||||||
|
parsedInput,
|
||||||
|
}: {
|
||||||
|
ctx: AuthenticatedActionClientCtx;
|
||||||
|
parsedInput: Record<string, any>;
|
||||||
|
}) => {
|
||||||
|
const hasNoOrganizations = await gethasNoOrganizations();
|
||||||
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
|
|
||||||
if (!hasNoOrganizations && !isMultiOrgEnabled) {
|
if (!hasNoOrganizations && !isMultiOrgEnabled) {
|
||||||
throw new OperationNotAllowedError("This action can only be performed on a fresh instance.");
|
throw new OperationNotAllowedError("This action can only be performed on a fresh instance.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOrganization = await createOrganization({
|
||||||
|
name: parsedInput.organizationName,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createMembership(newOrganization.id, ctx.user.id, {
|
||||||
|
role: "owner",
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.auditLoggingCtx.organizationId = newOrganization.id;
|
||||||
|
ctx.auditLoggingCtx.newObject = newOrganization;
|
||||||
|
|
||||||
|
return newOrganization;
|
||||||
}
|
}
|
||||||
|
)
|
||||||
const newOrganization = await createOrganization({
|
);
|
||||||
name: parsedInput.organizationName,
|
|
||||||
});
|
|
||||||
|
|
||||||
await createMembership(newOrganization.id, ctx.user.id, {
|
|
||||||
role: "owner",
|
|
||||||
accepted: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.organizationId = newOrganization.id;
|
|
||||||
ctx.auditLoggingCtx.newObject = newOrganization;
|
|
||||||
|
|
||||||
return newOrganization;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ checksums:
|
|||||||
common/allow: 3e39cc5940255e6bff0fea95c817dd43
|
common/allow: 3e39cc5940255e6bff0fea95c817dd43
|
||||||
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
|
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
|
||||||
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
|
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
|
||||||
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
|
|
||||||
common/and: dc75b95c804b16dc617a5f16f7393bca
|
common/and: dc75b95c804b16dc617a5f16f7393bca
|
||||||
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
|
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
|
||||||
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
|
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
|
||||||
@@ -149,9 +148,12 @@ checksums:
|
|||||||
common/copy: 627c00d2c850b9b45f7341a6ac01b6bb
|
common/copy: 627c00d2c850b9b45f7341a6ac01b6bb
|
||||||
common/copy_code: 704c13d9bc01caad29a1cf3179baa111
|
common/copy_code: 704c13d9bc01caad29a1cf3179baa111
|
||||||
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
|
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
|
||||||
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
|
common/count_attributes: 48805e836a9b50f9635ad00fed953058
|
||||||
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
|
common/count_contacts: 9f71d503455264f1eec1ae58894cf143
|
||||||
common/count_responses: 690118a456c01c5b4d437ae82b50b131
|
common/count_members: 31ce64ca63fdf95e02ab5543b6e2f717
|
||||||
|
common/count_questions: a7a34376a01eda781381fe7544541293
|
||||||
|
common/count_responses: 437e022825c7a08481d8f7e56926742d
|
||||||
|
common/count_selections: a1ec41682b9a7d8601c3905dfba34e16
|
||||||
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
|
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
|
||||||
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
|
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
|
||||||
common/create_survey: 1cfbba08d34876566d84b2960054a987
|
common/create_survey: 1cfbba08d34876566d84b2960054a987
|
||||||
@@ -165,6 +167,7 @@ checksums:
|
|||||||
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
|
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
|
||||||
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
|
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
|
||||||
common/delete: 8bcf303dd10a645b5baacb02b47d72c9
|
common/delete: 8bcf303dd10a645b5baacb02b47d72c9
|
||||||
|
common/delete_what: 718ddfcc1dec7f3e8b67856fba838267
|
||||||
common/description: e17686a22ffad04cc7bb70524ed4478b
|
common/description: e17686a22ffad04cc7bb70524ed4478b
|
||||||
common/dev_env: e650911d5e19ba256358e0cda154c005
|
common/dev_env: e650911d5e19ba256358e0cda154c005
|
||||||
common/development: 85211dbb918bda7a6e87649dcfc1b17a
|
common/development: 85211dbb918bda7a6e87649dcfc1b17a
|
||||||
@@ -180,6 +183,8 @@ checksums:
|
|||||||
common/download: 56b7d0834952b39ee394b44bd8179178
|
common/download: 56b7d0834952b39ee394b44bd8179178
|
||||||
common/draft: e8a92958ad300aacfe46c2bf6644927e
|
common/draft: e8a92958ad300aacfe46c2bf6644927e
|
||||||
common/duplicate: 27756566785c2b8463e21582c4bb619b
|
common/duplicate: 27756566785c2b8463e21582c4bb619b
|
||||||
|
common/duplicate_copy: 68d2201918610ca87c2914b61dc8010f
|
||||||
|
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
|
||||||
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
|
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
|
||||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||||
@@ -192,13 +197,16 @@ checksums:
|
|||||||
common/error: 3c95bcb32c2104b99a46f5b3dd015248
|
common/error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||||
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
|
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
|
||||||
common/error_component_title: ae68fa341a143aaa13a5ea30dd57a63e
|
common/error_component_title: ae68fa341a143aaa13a5ea30dd57a63e
|
||||||
|
common/error_loading_data: aaeffbfe4a2c2145442a57de524494be
|
||||||
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
||||||
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
||||||
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
||||||
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
|
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
|
||||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||||
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
|
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
|
||||||
|
common/filter: 626325a05e4c8800f7ede7012b0cadaf
|
||||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||||
|
common/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||||
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
|
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
|
||||||
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
|
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
|
||||||
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
|
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
|
||||||
@@ -211,6 +219,7 @@ checksums:
|
|||||||
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
|
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
|
||||||
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
|
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
|
||||||
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
|
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
|
||||||
|
common/id: c8886d38aeea2ed5f785aba4fc96784b
|
||||||
common/image: 048ba7a239de0fbd883ade8558415830
|
common/image: 048ba7a239de0fbd883ade8558415830
|
||||||
common/images: 9305827c28694866f49db42b4c51831f
|
common/images: 9305827c28694866f49db42b4c51831f
|
||||||
common/import: 348b8ab981de5b7f1fca6d7302263bbd
|
common/import: 348b8ab981de5b7f1fca6d7302263bbd
|
||||||
@@ -228,6 +237,7 @@ checksums:
|
|||||||
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
|
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
|
||||||
common/label: a5c71bf158481233f8215dbd38cc196b
|
common/label: a5c71bf158481233f8215dbd38cc196b
|
||||||
common/language: 277fd1a41cc237a437cd1d5e4a80463b
|
common/language: 277fd1a41cc237a437cd1d5e4a80463b
|
||||||
|
common/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||||
common/learn_more: e598091d132f890c37a6d4ed94f6d794
|
common/learn_more: e598091d132f890c37a6d4ed94f6d794
|
||||||
common/license_expired: 7af13535e320e4197989472c01387d2c
|
common/license_expired: 7af13535e320e4197989472c01387d2c
|
||||||
common/light_overlay: 0499907ea7b8405f4267b117998b5a78
|
common/light_overlay: 0499907ea7b8405f4267b117998b5a78
|
||||||
@@ -242,7 +252,6 @@ checksums:
|
|||||||
common/look_and_feel: 9125503712626d495cedec7a79f1418c
|
common/look_and_feel: 9125503712626d495cedec7a79f1418c
|
||||||
common/manage: a3d40c0267b81ae53c9598eaeb05087d
|
common/manage: a3d40c0267b81ae53c9598eaeb05087d
|
||||||
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
|
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
|
||||||
common/member: 1606dc30b369856b9dba1fe9aec425d2
|
|
||||||
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
|
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||||
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
|
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
|
||||||
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
|
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
|
||||||
@@ -254,6 +263,7 @@ checksums:
|
|||||||
common/move_down: 4f4de55743043355ad4a839aff2c48ff
|
common/move_down: 4f4de55743043355ad4a839aff2c48ff
|
||||||
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
|
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
|
||||||
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
|
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
|
||||||
|
common/my_product: ad022177062f9ef6e9acf33b13e889aa
|
||||||
common/name: 9368b5a047572b6051f334af5aa76819
|
common/name: 9368b5a047572b6051f334af5aa76819
|
||||||
common/new: 126d036fae5fb6b629728ecb97e6195b
|
common/new: 126d036fae5fb6b629728ecb97e6195b
|
||||||
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
|
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
|
||||||
@@ -349,8 +359,6 @@ checksums:
|
|||||||
common/select_teams: ae5d451929846ae6367562bc671a1af9
|
common/select_teams: ae5d451929846ae6367562bc671a1af9
|
||||||
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
|
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
|
||||||
common/selected_questions: beffe92d5272d99a0022f004e6a6ad73
|
common/selected_questions: beffe92d5272d99a0022f004e6a6ad73
|
||||||
common/selection: 25b570dc6339916a7aada2142aca0cd1
|
|
||||||
common/selections: 82f0681bf0208e25d7efedc23c556b8f
|
|
||||||
common/send_test_email: 2fd3ea40199b9589132ac826a5b0f3f5
|
common/send_test_email: 2fd3ea40199b9589132ac826a5b0f3f5
|
||||||
common/session_not_found: e9622df3170dbfd9636403bb0c22295b
|
common/session_not_found: e9622df3170dbfd9636403bb0c22295b
|
||||||
common/settings: 8df6777277469c1fd88cc18dde2f1cc3
|
common/settings: 8df6777277469c1fd88cc18dde2f1cc3
|
||||||
@@ -359,6 +367,7 @@ checksums:
|
|||||||
common/show_response_count: 609e5dc7c074d57e711a728fa2f8eb79
|
common/show_response_count: 609e5dc7c074d57e711a728fa2f8eb79
|
||||||
common/shown: 63e4ffb245c05e04b636446c3dbdd8df
|
common/shown: 63e4ffb245c05e04b636446c3dbdd8df
|
||||||
common/size: 227fadeeff951e041ff42031a11a4626
|
common/size: 227fadeeff951e041ff42031a11a4626
|
||||||
|
common/skip: b7f28dfa2f58b80b149bb82b392d0291
|
||||||
common/skipped: d496f0f667e1b4364b954db71335d4ef
|
common/skipped: d496f0f667e1b4364b954db71335d4ef
|
||||||
common/skips: 99de7579122a3fa6ec5e2a47f3fd8b34
|
common/skips: 99de7579122a3fa6ec5e2a47f3fd8b34
|
||||||
common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0
|
common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0
|
||||||
@@ -402,6 +411,7 @@ checksums:
|
|||||||
common/top_right: 241f95c923846911aaf13af6109333e5
|
common/top_right: 241f95c923846911aaf13af6109333e5
|
||||||
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
|
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
|
||||||
common/type: f04471a7ddac844b9ad145eb9911ef75
|
common/type: f04471a7ddac844b9ad145eb9911ef75
|
||||||
|
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
|
||||||
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
|
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
|
||||||
common/update: 079fc039262fd31b10532929685c2d1b
|
common/update: 079fc039262fd31b10532929685c2d1b
|
||||||
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
|
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
|
||||||
@@ -427,6 +437,7 @@ checksums:
|
|||||||
common/website_survey: 17513d25a07b6361768a15ec622b021b
|
common/website_survey: 17513d25a07b6361768a15ec622b021b
|
||||||
common/weeks: 545de30df4f44d3f6d1d344af6a10815
|
common/weeks: 545de30df4f44d3f6d1d344af6a10815
|
||||||
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
|
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
|
||||||
|
common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
|
||||||
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
|
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
|
||||||
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
|
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
|
||||||
common/workspace_creation_description: aea2f480ba0c54c5cabac72c9c900ddf
|
common/workspace_creation_description: aea2f480ba0c54c5cabac72c9c900ddf
|
||||||
@@ -600,27 +611,27 @@ checksums:
|
|||||||
environments/contacts/attribute_value: 34b0eaa85808b15cbc4be94c64d0146b
|
environments/contacts/attribute_value: 34b0eaa85808b15cbc4be94c64d0146b
|
||||||
environments/contacts/attribute_value_placeholder: 90fb17015de807031304d7a650a6cb8c
|
environments/contacts/attribute_value_placeholder: 90fb17015de807031304d7a650a6cb8c
|
||||||
environments/contacts/attributes_msg_attribute_limit_exceeded: a6c430860f307f9cc90c449f96a1284f
|
environments/contacts/attributes_msg_attribute_limit_exceeded: a6c430860f307f9cc90c449f96a1284f
|
||||||
environments/contacts/attributes_msg_attribute_type_validation_error: ed177ce83bd174ed6be7e889664f93a1
|
environments/contacts/attributes_msg_attribute_type_validation_error: bd70f9773ae873240d4cdb26a662334c
|
||||||
environments/contacts/attributes_msg_email_already_exists: a3ea1265e3db885f53d0e589aecf6260
|
environments/contacts/attributes_msg_email_already_exists: a3ea1265e3db885f53d0e589aecf6260
|
||||||
environments/contacts/attributes_msg_email_or_userid_required: 3be0e745cd3500c9a23bad2e25ad3147
|
environments/contacts/attributes_msg_email_or_userid_required: febc8b0cda4dd45d2c3cdb1ac2d45dcb
|
||||||
environments/contacts/attributes_msg_new_attribute_created: c4c7b27523058f43b70411d7aa6510e5
|
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
|
||||||
environments/contacts/attributes_msg_userid_already_exists: d2d95ece4b06507be18c9ba240b0a26b
|
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
|
||||||
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
|
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
|
||||||
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
|
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
|
||||||
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||||
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
||||||
environments/contacts/create_key: 0d385c354af8963acbe35cd646710f86
|
|
||||||
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
|
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
|
||||||
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
|
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
|
||||||
environments/contacts/custom_attributes: fffc7722742d1291b102dc737cf2fc9e
|
environments/contacts/custom_attributes: fffc7722742d1291b102dc737cf2fc9e
|
||||||
environments/contacts/data_type: 1ea127ba2c18d0d91fb0361cc6747e2b
|
environments/contacts/data_type: 1ea127ba2c18d0d91fb0361cc6747e2b
|
||||||
environments/contacts/data_type_cannot_be_changed: 22603f6193fdac3784eeef8315df70de
|
environments/contacts/data_type_cannot_be_changed: 22603f6193fdac3784eeef8315df70de
|
||||||
environments/contacts/data_type_description: 800bf4935df15e6cb14269e1b60c506e
|
environments/contacts/data_type_description: 800bf4935df15e6cb14269e1b60c506e
|
||||||
environments/contacts/date_value_required: e0e75b75ae4e8c02f03284954756adc9
|
environments/contacts/date_value_required: f70908e22f962731635c0bc9026da1c4
|
||||||
environments/contacts/delete_attribute_confirmation: 01d99b89eb3d27ff468d0db1b4aeb394
|
environments/contacts/delete_attribute_confirmation: 01d99b89eb3d27ff468d0db1b4aeb394
|
||||||
environments/contacts/delete_contact_confirmation: 2d45579e0bb4bc40fb1ee75b43c0e7a4
|
environments/contacts/delete_contact_confirmation: 2d45579e0bb4bc40fb1ee75b43c0e7a4
|
||||||
environments/contacts/delete_contact_confirmation_with_quotas: d3d17f13ae46ce04c126c82bf01299ac
|
environments/contacts/delete_contact_confirmation_with_quotas: d3d17f13ae46ce04c126c82bf01299ac
|
||||||
|
environments/contacts/displays: fcc4527002bd045021882be463b8ac72
|
||||||
environments/contacts/edit_attribute: 92a83c96a5d850e7d39002e8fd5898f4
|
environments/contacts/edit_attribute: 92a83c96a5d850e7d39002e8fd5898f4
|
||||||
environments/contacts/edit_attribute_description: 073a3084bb2f3b34ed1320ed1cd6db3c
|
environments/contacts/edit_attribute_description: 073a3084bb2f3b34ed1320ed1cd6db3c
|
||||||
environments/contacts/edit_attribute_values: 44e4e7a661cc1b59200bb07c710072a7
|
environments/contacts/edit_attribute_values: 44e4e7a661cc1b59200bb07c710072a7
|
||||||
@@ -632,6 +643,7 @@ checksums:
|
|||||||
environments/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
|
environments/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
|
||||||
environments/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
|
environments/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
|
||||||
environments/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
|
environments/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
|
||||||
|
environments/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
|
||||||
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
|
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
|
||||||
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
|
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
|
||||||
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||||
@@ -646,10 +658,12 @@ checksums:
|
|||||||
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
|
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
|
||||||
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
|
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
|
||||||
environments/contacts/select_attribute_key: 673a6683fab41b387d921841cded7e38
|
environments/contacts/select_attribute_key: 673a6683fab41b387d921841cded7e38
|
||||||
|
environments/contacts/survey_viewed: 646d413218626787b0373ffd71cb7451
|
||||||
|
environments/contacts/survey_viewed_at: 2ab535237af5c3c3f33acc792a7e70a4
|
||||||
environments/contacts/system_attributes: eadb6a8888c7b32c0e68881f945ae9b6
|
environments/contacts/system_attributes: eadb6a8888c7b32c0e68881f945ae9b6
|
||||||
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
|
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
|
||||||
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
|
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
|
||||||
environments/contacts/upload_contacts_error_attribute_type_mismatch: 70a60f0886ce767c00defa7d4aad0f93
|
environments/contacts/upload_contacts_error_attribute_type_mismatch: 5f1ba60a88f166c5d387f49f1040c675
|
||||||
environments/contacts/upload_contacts_error_duplicate_mappings: 9c1e1f07e476226bad98ccfa07979fec
|
environments/contacts/upload_contacts_error_duplicate_mappings: 9c1e1f07e476226bad98ccfa07979fec
|
||||||
environments/contacts/upload_contacts_error_file_too_large: 0c1837286c55d18049277465bc2444c1
|
environments/contacts/upload_contacts_error_file_too_large: 0c1837286c55d18049277465bc2444c1
|
||||||
environments/contacts/upload_contacts_error_generic: 3a8d35a421b377198361af9972392693
|
environments/contacts/upload_contacts_error_generic: 3a8d35a421b377198361af9972392693
|
||||||
@@ -712,7 +726,12 @@ checksums:
|
|||||||
environments/integrations/google_sheets/link_google_sheet: fa78146ae26ce5b1d2aaf2678f628943
|
environments/integrations/google_sheets/link_google_sheet: fa78146ae26ce5b1d2aaf2678f628943
|
||||||
environments/integrations/google_sheets/link_new_sheet: 8ad2ea8708f50ed184c00b84577b325e
|
environments/integrations/google_sheets/link_new_sheet: 8ad2ea8708f50ed184c00b84577b325e
|
||||||
environments/integrations/google_sheets/no_integrations_yet: ea46f7747937baf48a47a4c1b1776aee
|
environments/integrations/google_sheets/no_integrations_yet: ea46f7747937baf48a47a4c1b1776aee
|
||||||
|
environments/integrations/google_sheets/reconnect_button: 8992a0f250278c116cb26be448b68ba2
|
||||||
|
environments/integrations/google_sheets/reconnect_button_description: 851fd2fda57211293090f371d5b2c734
|
||||||
|
environments/integrations/google_sheets/reconnect_button_tooltip: 210dd97470fde8264d2c076db3c98fde
|
||||||
|
environments/integrations/google_sheets/spreadsheet_permission_error: 24a55163c657593e88c28fdc8628acdd
|
||||||
environments/integrations/google_sheets/spreadsheet_url: b1665f96e6ecce23ea2d9196f4a3e5dd
|
environments/integrations/google_sheets/spreadsheet_url: b1665f96e6ecce23ea2d9196f4a3e5dd
|
||||||
|
environments/integrations/google_sheets/token_expired_error: 555d34c18c554ec8ac66614f21bd44fc
|
||||||
environments/integrations/include_created_at: 8011355b13e28e638d74e6f3d68a2bbf
|
environments/integrations/include_created_at: 8011355b13e28e638d74e6f3d68a2bbf
|
||||||
environments/integrations/include_hidden_fields: 25f0ea5ca1c6ead2cd121f8754cb8d72
|
environments/integrations/include_hidden_fields: 25f0ea5ca1c6ead2cd121f8754cb8d72
|
||||||
environments/integrations/include_metadata: 750091d965d7cc8d02468b5239816dc5
|
environments/integrations/include_metadata: 750091d965d7cc8d02468b5239816dc5
|
||||||
@@ -907,7 +926,7 @@ checksums:
|
|||||||
environments/settings/billing/current_plan: 7497746eb3b4897ff953b1aa8c7c9f63
|
environments/settings/billing/current_plan: 7497746eb3b4897ff953b1aa8c7c9f63
|
||||||
environments/settings/billing/current_tier_limit: a6875905f376953b12fdf5ae8fc7e051
|
environments/settings/billing/current_tier_limit: a6875905f376953b12fdf5ae8fc7e051
|
||||||
environments/settings/billing/custom: fee41bfbe59e71721d8648e7a95ec9c5
|
environments/settings/billing/custom: fee41bfbe59e71721d8648e7a95ec9c5
|
||||||
environments/settings/billing/custom_contacts_limit: 380f13656e3b9e1a6cb3fffd1a1cc263
|
environments/settings/billing/custom_contacts_limit: c9c10a51c470d9b5661d47317eb8f94e
|
||||||
environments/settings/billing/custom_response_limit: 96ef34d587001a7b479f3f6f7c9e66dc
|
environments/settings/billing/custom_response_limit: 96ef34d587001a7b479f3f6f7c9e66dc
|
||||||
environments/settings/billing/custom_workspace_limit: 3f6f7f901dfc245028ce938e3d9aa2c6
|
environments/settings/billing/custom_workspace_limit: 3f6f7f901dfc245028ce938e3d9aa2c6
|
||||||
environments/settings/billing/email_embedded_surveys: bb1f558f9061287666041c08384ad1d4
|
environments/settings/billing/email_embedded_surveys: bb1f558f9061287666041c08384ad1d4
|
||||||
@@ -981,7 +1000,7 @@ checksums:
|
|||||||
environments/settings/enterprise/recheck_license_invalid: 58f41bc208692b7d53b975dfcf9f4ad8
|
environments/settings/enterprise/recheck_license_invalid: 58f41bc208692b7d53b975dfcf9f4ad8
|
||||||
environments/settings/enterprise/recheck_license_success: 700ddd805be904a415f614de3df1da78
|
environments/settings/enterprise/recheck_license_success: 700ddd805be904a415f614de3df1da78
|
||||||
environments/settings/enterprise/recheck_license_unreachable: 0ca81bd89595a9da24bc94dcef132175
|
environments/settings/enterprise/recheck_license_unreachable: 0ca81bd89595a9da24bc94dcef132175
|
||||||
environments/settings/enterprise/rechecking: 54c454aa8e4d27363543349b7c2a28bc
|
environments/settings/enterprise/rechecking: 68dacda4ccacfc808c5105e99dad4b71
|
||||||
environments/settings/enterprise/request_30_day_trial_license: 8d5a1b5d9f0790783693122ac30c16ef
|
environments/settings/enterprise/request_30_day_trial_license: 8d5a1b5d9f0790783693122ac30c16ef
|
||||||
environments/settings/enterprise/saml_sso: 86b76024524fc585b2c3950126ef6f62
|
environments/settings/enterprise/saml_sso: 86b76024524fc585b2c3950126ef6f62
|
||||||
environments/settings/enterprise/service_level_agreement: e31e74f66f5c7c79e82878f4f68abc37
|
environments/settings/enterprise/service_level_agreement: e31e74f66f5c7c79e82878f4f68abc37
|
||||||
@@ -1007,7 +1026,7 @@ checksums:
|
|||||||
environments/settings/general/email_customization_preview_email_heading: 8b798cb8438b3dd356c02dab33b4c897
|
environments/settings/general/email_customization_preview_email_heading: 8b798cb8438b3dd356c02dab33b4c897
|
||||||
environments/settings/general/email_customization_preview_email_text: fa6ae92403cc8f3c35c03e6c94cbde51
|
environments/settings/general/email_customization_preview_email_text: fa6ae92403cc8f3c35c03e6c94cbde51
|
||||||
environments/settings/general/error_deleting_organization_please_try_again: 7f0fe257d4a0b40bff025408a7766706
|
environments/settings/general/error_deleting_organization_please_try_again: 7f0fe257d4a0b40bff025408a7766706
|
||||||
environments/settings/general/from_your_organization: 4b7970431edb3d0f13c394dbd755a055
|
environments/settings/general/from_your_organization: 9ebd6dcd79f7bfad3fea46ed2e3133d2
|
||||||
environments/settings/general/invitation_sent_once_more: e6e5ea066810f9dcb65788aa4f05d6e2
|
environments/settings/general/invitation_sent_once_more: e6e5ea066810f9dcb65788aa4f05d6e2
|
||||||
environments/settings/general/invite_deleted_successfully: 1c7dca6d0f6870d945288e38cfd2f943
|
environments/settings/general/invite_deleted_successfully: 1c7dca6d0f6870d945288e38cfd2f943
|
||||||
environments/settings/general/invite_expires_on: 6fd2356ad91a5f189070c43855904bb4
|
environments/settings/general/invite_expires_on: 6fd2356ad91a5f189070c43855904bb4
|
||||||
@@ -1054,7 +1073,7 @@ checksums:
|
|||||||
environments/settings/notifications/notification_settings_updated: 948fb98c8a9225ba6b17c43365576df9
|
environments/settings/notifications/notification_settings_updated: 948fb98c8a9225ba6b17c43365576df9
|
||||||
environments/settings/notifications/set_up_an_alert_to_get_an_email_on_new_responses: 4168ae6f5962244a4894a22b56fbb55f
|
environments/settings/notifications/set_up_an_alert_to_get_an_email_on_new_responses: 4168ae6f5962244a4894a22b56fbb55f
|
||||||
environments/settings/notifications/use_the_integration: 765ec5b4959cb096234e8b9a0d6b01f5
|
environments/settings/notifications/use_the_integration: 765ec5b4959cb096234e8b9a0d6b01f5
|
||||||
environments/settings/notifications/want_to_loop_in_organization_mates: 147cb4d1ebcc957e5e965914ae9d7d4d
|
environments/settings/notifications/want_to_loop_in_organization_mates: 46b06df5be0d610c2c3b552a5553ee2b
|
||||||
environments/settings/notifications/you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore: e158e20fc4fa511ce457d99d0c865c3c
|
environments/settings/notifications/you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore: e158e20fc4fa511ce457d99d0c865c3c
|
||||||
environments/settings/notifications/you_will_not_receive_any_more_emails_for_responses_on_this_survey: 7e2a88fe0d0489ebdc8f2621584b1c29
|
environments/settings/notifications/you_will_not_receive_any_more_emails_for_responses_on_this_survey: 7e2a88fe0d0489ebdc8f2621584b1c29
|
||||||
environments/settings/profile/account_deletion_consequences_warning: b87b9eebe0a373eb6debe52db73e5aba
|
environments/settings/profile/account_deletion_consequences_warning: b87b9eebe0a373eb6debe52db73e5aba
|
||||||
@@ -1162,6 +1181,7 @@ checksums:
|
|||||||
environments/surveys/edit/add_fallback_placeholder: 0e77ea487ddd7bc7fc2f1574b018dc08
|
environments/surveys/edit/add_fallback_placeholder: 0e77ea487ddd7bc7fc2f1574b018dc08
|
||||||
environments/surveys/edit/add_hidden_field_id: a8f55b51b790cf5f4d898af7770ad1ed
|
environments/surveys/edit/add_hidden_field_id: a8f55b51b790cf5f4d898af7770ad1ed
|
||||||
environments/surveys/edit/add_highlight_border: 66f52b21fbb9aa6561c98a090abaaf8f
|
environments/surveys/edit/add_highlight_border: 66f52b21fbb9aa6561c98a090abaaf8f
|
||||||
|
environments/surveys/edit/add_highlight_border_description: fe548fe03ea10ef5cd9e553d6812b3c2
|
||||||
environments/surveys/edit/add_logic: f234c9f1393a9ed4792dfbd15838c951
|
environments/surveys/edit/add_logic: f234c9f1393a9ed4792dfbd15838c951
|
||||||
environments/surveys/edit/add_none_of_the_above: dbe1ada4512d6c3f80c54c8fac107ec6
|
environments/surveys/edit/add_none_of_the_above: dbe1ada4512d6c3f80c54c8fac107ec6
|
||||||
environments/surveys/edit/add_option: 143c54f0b201067fe5159284d6daeca2
|
environments/surveys/edit/add_option: 143c54f0b201067fe5159284d6daeca2
|
||||||
@@ -1178,12 +1198,14 @@ checksums:
|
|||||||
environments/surveys/edit/adjust_survey_closed_message: ae6f38c9daf08656362bd84459a312fa
|
environments/surveys/edit/adjust_survey_closed_message: ae6f38c9daf08656362bd84459a312fa
|
||||||
environments/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
|
environments/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
|
||||||
environments/surveys/edit/adjust_the_theme_in_the: bccdafda8af5871513266f668b55d690
|
environments/surveys/edit/adjust_the_theme_in_the: bccdafda8af5871513266f668b55d690
|
||||||
|
environments/surveys/edit/all_are_true: 05d02c5afac857da530b73dcf18dd8e4
|
||||||
environments/surveys/edit/all_other_answers_will_continue_to: 9a5d09eea42ff5fd1c18cc58a14dcabd
|
environments/surveys/edit/all_other_answers_will_continue_to: 9a5d09eea42ff5fd1c18cc58a14dcabd
|
||||||
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
|
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
|
||||||
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
|
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
|
||||||
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
|
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
|
||||||
environments/surveys/edit/and_launch_surveys_in_your_website_or_app: a3edcdb4aea792a27d90aad1930f001a
|
environments/surveys/edit/and_launch_surveys_in_your_website_or_app: a3edcdb4aea792a27d90aad1930f001a
|
||||||
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
|
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
|
||||||
|
environments/surveys/edit/any_is_true: 32c9f3998984fd32a2b5bc53f2d97429
|
||||||
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
|
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
|
||||||
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
|
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
|
||||||
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
|
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
|
||||||
@@ -1214,7 +1236,7 @@ checksums:
|
|||||||
environments/surveys/edit/calculate: c5fcf8d3a38706ae2071b6f78339ec68
|
environments/surveys/edit/calculate: c5fcf8d3a38706ae2071b6f78339ec68
|
||||||
environments/surveys/edit/capture_a_new_action_to_trigger_a_survey_on: 73410e9665a37bc4a9747db5d683d36c
|
environments/surveys/edit/capture_a_new_action_to_trigger_a_survey_on: 73410e9665a37bc4a9747db5d683d36c
|
||||||
environments/surveys/edit/capture_ip_address: e950f924f1c0b52f8c5b06ca118e049f
|
environments/surveys/edit/capture_ip_address: e950f924f1c0b52f8c5b06ca118e049f
|
||||||
environments/surveys/edit/capture_ip_address_description: 932d1b4ad68594d06d4eaf0212f9570c
|
environments/surveys/edit/capture_ip_address_description: f6bf6046db16667237511265e9fba5f0
|
||||||
environments/surveys/edit/capture_new_action: 0aa2a3c399b62b1a52307deedf4922e8
|
environments/surveys/edit/capture_new_action: 0aa2a3c399b62b1a52307deedf4922e8
|
||||||
environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc
|
environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc
|
||||||
environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83
|
environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83
|
||||||
@@ -1225,7 +1247,7 @@ checksums:
|
|||||||
environments/surveys/edit/casual: 6534fe68718fade470a9031f7390409e
|
environments/surveys/edit/casual: 6534fe68718fade470a9031f7390409e
|
||||||
environments/surveys/edit/caution_edit_duplicate: ee93bccb34fcd707e1ef4735f1c2fc31
|
environments/surveys/edit/caution_edit_duplicate: ee93bccb34fcd707e1ef4735f1c2fc31
|
||||||
environments/surveys/edit/caution_edit_published_survey: faf7fc57c776f2a9104d143e20044486
|
environments/surveys/edit/caution_edit_published_survey: faf7fc57c776f2a9104d143e20044486
|
||||||
environments/surveys/edit/caution_explanation_intro: d6bd8cfdca654353cdced650c1416834
|
environments/surveys/edit/caution_explanation_intro: 143ed2e04bf5bf0bcc0752cb87acda51
|
||||||
environments/surveys/edit/caution_explanation_new_responses_separated: 387286482b7ecd725ce20521dacbc485
|
environments/surveys/edit/caution_explanation_new_responses_separated: 387286482b7ecd725ce20521dacbc485
|
||||||
environments/surveys/edit/caution_explanation_only_new_responses_in_summary: 1ad5f649c7784a58bfa9d62304780514
|
environments/surveys/edit/caution_explanation_only_new_responses_in_summary: 1ad5f649c7784a58bfa9d62304780514
|
||||||
environments/surveys/edit/caution_explanation_responses_are_safe: 090ff00b7922a49c273e67c5f364730d
|
environments/surveys/edit/caution_explanation_responses_are_safe: 090ff00b7922a49c273e67c5f364730d
|
||||||
@@ -1360,7 +1382,6 @@ checksums:
|
|||||||
environments/surveys/edit/follow_ups_modal_updated_successfull_toast: 61204fada3231f4f1fe3866e87e1130a
|
environments/surveys/edit/follow_ups_modal_updated_successfull_toast: 61204fada3231f4f1fe3866e87e1130a
|
||||||
environments/surveys/edit/follow_ups_new: 224c779d252b3e75086e4ed456ba2548
|
environments/surveys/edit/follow_ups_new: 224c779d252b3e75086e4ed456ba2548
|
||||||
environments/surveys/edit/follow_ups_upgrade_button_text: 4cd167527fc6cdb5b0bfc9b486b142a8
|
environments/surveys/edit/follow_ups_upgrade_button_text: 4cd167527fc6cdb5b0bfc9b486b142a8
|
||||||
environments/surveys/edit/form_styling: 1278a2db4257b5500474161133acc857
|
|
||||||
environments/surveys/edit/formbricks_sdk_is_not_connected: 35165b0cac182a98408007a378cc677e
|
environments/surveys/edit/formbricks_sdk_is_not_connected: 35165b0cac182a98408007a378cc677e
|
||||||
environments/surveys/edit/four_points: b289628a6b8a6cd0f7d17a14ca6cd7bf
|
environments/surveys/edit/four_points: b289628a6b8a6cd0f7d17a14ca6cd7bf
|
||||||
environments/surveys/edit/heading: 79e9dfa461f38a239d34b9833ca103f1
|
environments/surveys/edit/heading: 79e9dfa461f38a239d34b9833ca103f1
|
||||||
@@ -1450,7 +1471,7 @@ checksums:
|
|||||||
environments/surveys/edit/option_idx: 9eb69cb3d19797daf1300699b441ef9d
|
environments/surveys/edit/option_idx: 9eb69cb3d19797daf1300699b441ef9d
|
||||||
environments/surveys/edit/option_used_in_logic_error: c682ac2cfd286c3cc07dd21ac863dd4c
|
environments/surveys/edit/option_used_in_logic_error: c682ac2cfd286c3cc07dd21ac863dd4c
|
||||||
environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943
|
environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943
|
||||||
environments/surveys/edit/options: 59156082418d80acb211f973b1218f11
|
environments/surveys/edit/options: 39ba06e709561761d922ca1254155d5f
|
||||||
environments/surveys/edit/options_used_in_logic_bulk_error: 1720e7a01a0bcb67c152cfe6a68c5355
|
environments/surveys/edit/options_used_in_logic_bulk_error: 1720e7a01a0bcb67c152cfe6a68c5355
|
||||||
environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f
|
environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f
|
||||||
environments/surveys/edit/overwrite_global_waiting_time: bf39ba91e35742e1ff3a281a18e9159c
|
environments/surveys/edit/overwrite_global_waiting_time: bf39ba91e35742e1ff3a281a18e9159c
|
||||||
@@ -1475,6 +1496,7 @@ checksums:
|
|||||||
environments/surveys/edit/question_deleted: ecdeb22b81ae2d732656a7742c1eec7b
|
environments/surveys/edit/question_deleted: ecdeb22b81ae2d732656a7742c1eec7b
|
||||||
environments/surveys/edit/question_duplicated: 3f02439fd0a8b818bc84c1b1b473898c
|
environments/surveys/edit/question_duplicated: 3f02439fd0a8b818bc84c1b1b473898c
|
||||||
environments/surveys/edit/question_id_updated: e8d94dbefcbad00c7464b3d1fb0ee81a
|
environments/surveys/edit/question_id_updated: e8d94dbefcbad00c7464b3d1fb0ee81a
|
||||||
|
environments/surveys/edit/question_number: 742636e9d2d5dcc7ee6ca1b3016bcee7
|
||||||
environments/surveys/edit/question_used_in_logic_warning_text: ec78767a7cf335222d41b98cb5baa6be
|
environments/surveys/edit/question_used_in_logic_warning_text: ec78767a7cf335222d41b98cb5baa6be
|
||||||
environments/surveys/edit/question_used_in_logic_warning_title: 4bb8528cdc3b8649c194487067737f6d
|
environments/surveys/edit/question_used_in_logic_warning_title: 4bb8528cdc3b8649c194487067737f6d
|
||||||
environments/surveys/edit/question_used_in_quota: ceb5e88f6916e4863e589c6be030bb3b
|
environments/surveys/edit/question_used_in_quota: ceb5e88f6916e4863e589c6be030bb3b
|
||||||
@@ -1531,7 +1553,7 @@ checksums:
|
|||||||
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
|
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
|
||||||
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
|
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
|
||||||
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
|
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
|
||||||
environments/surveys/edit/roundness_description: bde131aa5674836416dcdf2ff517d899
|
environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b
|
||||||
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
|
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
|
||||||
environments/surveys/edit/rows: 8f41f34e6ca28221cf1ebd948af4c151
|
environments/surveys/edit/rows: 8f41f34e6ca28221cf1ebd948af4c151
|
||||||
environments/surveys/edit/save_and_close: 6ede705b3f82f30269ff3054a5049e34
|
environments/surveys/edit/save_and_close: 6ede705b3f82f30269ff3054a5049e34
|
||||||
@@ -1577,6 +1599,7 @@ checksums:
|
|||||||
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
|
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
|
||||||
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
|
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
|
||||||
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
|
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
|
||||||
|
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
|
||||||
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
|
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
|
||||||
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
|
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
|
||||||
environments/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
|
environments/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
|
||||||
@@ -1638,7 +1661,7 @@ checksums:
|
|||||||
environments/surveys/edit/validation/pattern: c6f01d7bc9baa21a40ea38fa986bd5a0
|
environments/surveys/edit/validation/pattern: c6f01d7bc9baa21a40ea38fa986bd5a0
|
||||||
environments/surveys/edit/validation/phone: bcd7bd37a475ab1f80ea4c5b4d4d0bb5
|
environments/surveys/edit/validation/phone: bcd7bd37a475ab1f80ea4c5b4d4d0bb5
|
||||||
environments/surveys/edit/validation/rank_all_options: a885523e9d7820c9b0529bca37e48ccc
|
environments/surveys/edit/validation/rank_all_options: a885523e9d7820c9b0529bca37e48ccc
|
||||||
environments/surveys/edit/validation/select_file_extensions: 208ccb7bd4dde20b0d79bdd1fa763076
|
environments/surveys/edit/validation/select_file_extensions: cbe67b01658ff5f654e4e18fd6fa34ec
|
||||||
environments/surveys/edit/validation/select_option: 53ba37697cca1f6c7d57ecca53ea063e
|
environments/surveys/edit/validation/select_option: 53ba37697cca1f6c7d57ecca53ea063e
|
||||||
environments/surveys/edit/validation/start_date: 881de78c79b56f5ceb9b7103bf23cb2c
|
environments/surveys/edit/validation/start_date: 881de78c79b56f5ceb9b7103bf23cb2c
|
||||||
environments/surveys/edit/validation/url: 4006a4d8dfac013758f0053f6aa67cdd
|
environments/surveys/edit/validation/url: 4006a4d8dfac013758f0053f6aa67cdd
|
||||||
@@ -1663,9 +1686,9 @@ checksums:
|
|||||||
environments/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
|
environments/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
|
||||||
environments/surveys/edit/waiting_time_across_surveys_description: 6edafaeb3ccd8cadde81175776636c8e
|
environments/surveys/edit/waiting_time_across_surveys_description: 6edafaeb3ccd8cadde81175776636c8e
|
||||||
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
|
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
|
||||||
|
environments/surveys/edit/when: a40ad3eed1b75e76226290eeb9bb20cd
|
||||||
environments/surveys/edit/without_a_filter_all_of_your_users_can_be_surveyed: 451990569c61f25d01044cc45b1ce122
|
environments/surveys/edit/without_a_filter_all_of_your_users_can_be_surveyed: 451990569c61f25d01044cc45b1ce122
|
||||||
environments/surveys/edit/you_have_not_created_a_segment_yet: c6658bd1cee9c5c957c675db044708dd
|
environments/surveys/edit/you_have_not_created_a_segment_yet: c6658bd1cee9c5c957c675db044708dd
|
||||||
environments/surveys/edit/you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations: 04241177ba989ef4c1d8c01e1a7b8541
|
|
||||||
environments/surveys/edit/your_description_here_recall_information_with: 60f73a3cc9bdb9afea2166a7db8fd618
|
environments/surveys/edit/your_description_here_recall_information_with: 60f73a3cc9bdb9afea2166a7db8fd618
|
||||||
environments/surveys/edit/your_question_here_recall_information_with: 6395bd54f5167830c9d662ba403da167
|
environments/surveys/edit/your_question_here_recall_information_with: 6395bd54f5167830c9d662ba403da167
|
||||||
environments/surveys/edit/your_web_app: 07234bed03a33330dc50ae9fcf0174f3
|
environments/surveys/edit/your_web_app: 07234bed03a33330dc50ae9fcf0174f3
|
||||||
@@ -1728,7 +1751,7 @@ checksums:
|
|||||||
environments/surveys/share/anonymous_links/disable_single_use_link_modal_button: e26f8f680c7c21dcade0b96543d7fb0b
|
environments/surveys/share/anonymous_links/disable_single_use_link_modal_button: e26f8f680c7c21dcade0b96543d7fb0b
|
||||||
environments/surveys/share/anonymous_links/disable_single_use_link_modal_description: 30e41451528ee1ec80cad04ffd90d64b
|
environments/surveys/share/anonymous_links/disable_single_use_link_modal_description: 30e41451528ee1ec80cad04ffd90d64b
|
||||||
environments/surveys/share/anonymous_links/generate_and_download_links: bb28f5a4f6d7cc4c8b4ca94b923b2574
|
environments/surveys/share/anonymous_links/generate_and_download_links: bb28f5a4f6d7cc4c8b4ca94b923b2574
|
||||||
environments/surveys/share/anonymous_links/generate_links_error: cc37320a3b3e63274982eb37c06569dd
|
environments/surveys/share/anonymous_links/generate_links_error: ef2d0c17d261ca498defe91c6f5e8901
|
||||||
environments/surveys/share/anonymous_links/multi_use_link: 99d94762889174aa0643416accaa2427
|
environments/surveys/share/anonymous_links/multi_use_link: 99d94762889174aa0643416accaa2427
|
||||||
environments/surveys/share/anonymous_links/multi_use_link_description: 6ac5e527e647ebecdf795c5acfd744e9
|
environments/surveys/share/anonymous_links/multi_use_link_description: 6ac5e527e647ebecdf795c5acfd744e9
|
||||||
environments/surveys/share/anonymous_links/multi_use_powers_other_channels_description: b3758f9e7a8488d5ef3101584715ef9f
|
environments/surveys/share/anonymous_links/multi_use_powers_other_channels_description: b3758f9e7a8488d5ef3101584715ef9f
|
||||||
@@ -1820,7 +1843,7 @@ checksums:
|
|||||||
environments/surveys/share/social_media/source_tracking_enabled: 3c0a4f7aecda5f1ca8c1991f7b36c05b
|
environments/surveys/share/social_media/source_tracking_enabled: 3c0a4f7aecda5f1ca8c1991f7b36c05b
|
||||||
environments/surveys/share/social_media/source_tracking_enabled_alert_description: 4f4929310e307e946281b96943e834e1
|
environments/surveys/share/social_media/source_tracking_enabled_alert_description: 4f4929310e307e946281b96943e834e1
|
||||||
environments/surveys/share/social_media/title: 1bf4899b063ee8f02f7188576555828b
|
environments/surveys/share/social_media/title: 1bf4899b063ee8f02f7188576555828b
|
||||||
environments/surveys/summary/added_filter_for_responses_where_answer_to_question: 5bddf0d4f771efd06d58441d11fa5091
|
environments/surveys/summary/added_filter_for_responses_where_answer_to_question: ca743504c3f34b4e5fa98e3e79100624
|
||||||
environments/surveys/summary/added_filter_for_responses_where_answer_to_question_is_skipped: 74ca713c491cfc33751a5db3de972821
|
environments/surveys/summary/added_filter_for_responses_where_answer_to_question_is_skipped: 74ca713c491cfc33751a5db3de972821
|
||||||
environments/surveys/summary/aggregated: 9d4e77225d5952abed414fffd828c078
|
environments/surveys/summary/aggregated: 9d4e77225d5952abed414fffd828c078
|
||||||
environments/surveys/summary/all_responses_csv: 16c0c211853f0839a79f1127ec679ca2
|
environments/surveys/summary/all_responses_csv: 16c0c211853f0839a79f1127ec679ca2
|
||||||
@@ -1847,6 +1870,7 @@ checksums:
|
|||||||
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
|
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
|
||||||
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
|
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
|
||||||
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
|
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
|
||||||
|
environments/surveys/summary/impressions_identified_only: 10f8c491463c73b8e6534314ee00d165
|
||||||
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
|
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
|
||||||
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
|
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
|
||||||
environments/surveys/summary/in_app/connection_title: 29e8a40ad6a7fdb5af5ee9451a70a9aa
|
environments/surveys/summary/in_app/connection_title: 29e8a40ad6a7fdb5af5ee9451a70a9aa
|
||||||
@@ -1887,6 +1911,7 @@ checksums:
|
|||||||
environments/surveys/summary/last_quarter: 2e565a81de9b3d7b1ee709ebb6f6eda1
|
environments/surveys/summary/last_quarter: 2e565a81de9b3d7b1ee709ebb6f6eda1
|
||||||
environments/surveys/summary/last_year: fe7c268a48bf85bc40da000e6e437637
|
environments/surveys/summary/last_year: fe7c268a48bf85bc40da000e6e437637
|
||||||
environments/surveys/summary/limit: 347051f1a068e01e8c4e4f6744d8e727
|
environments/surveys/summary/limit: 347051f1a068e01e8c4e4f6744d8e727
|
||||||
|
environments/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
|
||||||
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||||
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
||||||
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
||||||
@@ -1909,6 +1934,7 @@ checksums:
|
|||||||
environments/surveys/summary/starts: 3153990a4ade414f501a7e63ab771362
|
environments/surveys/summary/starts: 3153990a4ade414f501a7e63ab771362
|
||||||
environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
|
environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
|
||||||
environments/surveys/summary/survey_reset_successfully: f53db36a28980ef4766215cf13f01e51
|
environments/surveys/summary/survey_reset_successfully: f53db36a28980ef4766215cf13f01e51
|
||||||
|
environments/surveys/summary/survey_results: b7d86f636beaee2b4d5746bdda058d07
|
||||||
environments/surveys/summary/this_month: 50845a38865204a97773c44dcd2ebb90
|
environments/surveys/summary/this_month: 50845a38865204a97773c44dcd2ebb90
|
||||||
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
|
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
|
||||||
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
|
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
|
||||||
@@ -2014,7 +2040,7 @@ checksums:
|
|||||||
environments/workspace/look/add_background_color_description: adb6fcb392862b3d0e9420d9b5405ddb
|
environments/workspace/look/add_background_color_description: adb6fcb392862b3d0e9420d9b5405ddb
|
||||||
environments/workspace/look/advanced_styling_field_border_radius: 63b8f3541a9792d705e67d5aca7b6451
|
environments/workspace/look/advanced_styling_field_border_radius: 63b8f3541a9792d705e67d5aca7b6451
|
||||||
environments/workspace/look/advanced_styling_field_button_bg: fc103ab926721e6213d39cc1f913c018
|
environments/workspace/look/advanced_styling_field_button_bg: fc103ab926721e6213d39cc1f913c018
|
||||||
environments/workspace/look/advanced_styling_field_button_bg_description: 9f14ec79ed40c0d3eb168cc46a9e0a14
|
environments/workspace/look/advanced_styling_field_button_bg_description: b8195c91717d8ba6e14cd83396caebe5
|
||||||
environments/workspace/look/advanced_styling_field_button_border_radius_description: 5677ee84511896ab9c369c0aced4c352
|
environments/workspace/look/advanced_styling_field_button_border_radius_description: 5677ee84511896ab9c369c0aced4c352
|
||||||
environments/workspace/look/advanced_styling_field_button_font_size_description: 59508854b0101a89fab8250f79c0f3ba
|
environments/workspace/look/advanced_styling_field_button_font_size_description: 59508854b0101a89fab8250f79c0f3ba
|
||||||
environments/workspace/look/advanced_styling_field_button_font_weight_description: d3dab571b0f1bc09d645be66c6686a06
|
environments/workspace/look/advanced_styling_field_button_font_weight_description: d3dab571b0f1bc09d645be66c6686a06
|
||||||
@@ -2042,7 +2068,7 @@ checksums:
|
|||||||
environments/workspace/look/advanced_styling_field_indicator_bg_description: 7eb3b54a8b331354ec95c0dc1545c620
|
environments/workspace/look/advanced_styling_field_indicator_bg_description: 7eb3b54a8b331354ec95c0dc1545c620
|
||||||
environments/workspace/look/advanced_styling_field_input_border_radius_description: 0007f1bb572b35d9a3720daeb7a55617
|
environments/workspace/look/advanced_styling_field_input_border_radius_description: 0007f1bb572b35d9a3720daeb7a55617
|
||||||
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
|
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
|
||||||
environments/workspace/look/advanced_styling_field_input_height_description: e19ec0dc432478def0fd1199ad765e38
|
environments/workspace/look/advanced_styling_field_input_height_description: eff99285b2ce1141c3c9743f7c96f63c
|
||||||
environments/workspace/look/advanced_styling_field_input_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
|
environments/workspace/look/advanced_styling_field_input_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
|
||||||
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
|
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
|
||||||
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
|
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
|
||||||
@@ -2051,6 +2077,8 @@ checksums:
|
|||||||
environments/workspace/look/advanced_styling_field_input_text_description: 460450df24ea0cc902710118a5000feb
|
environments/workspace/look/advanced_styling_field_input_text_description: 460450df24ea0cc902710118a5000feb
|
||||||
environments/workspace/look/advanced_styling_field_option_bg: 0ceaed10d99ed4ad83cb0934ab970174
|
environments/workspace/look/advanced_styling_field_option_bg: 0ceaed10d99ed4ad83cb0934ab970174
|
||||||
environments/workspace/look/advanced_styling_field_option_bg_description: 6cd6ccecbbb9f2f19439d7c682eb67c1
|
environments/workspace/look/advanced_styling_field_option_bg_description: 6cd6ccecbbb9f2f19439d7c682eb67c1
|
||||||
|
environments/workspace/look/advanced_styling_field_option_border: aa478eb148515b6a2637fb144ff72028
|
||||||
|
environments/workspace/look/advanced_styling_field_option_border_description: 8f75b740e8dcb7f6cfeff2e5d5ca7c92
|
||||||
environments/workspace/look/advanced_styling_field_option_border_radius_description: 23f81c25b2681a7c9e2c4f2e7d2e0656
|
environments/workspace/look/advanced_styling_field_option_border_radius_description: 23f81c25b2681a7c9e2c4f2e7d2e0656
|
||||||
environments/workspace/look/advanced_styling_field_option_font_size_description: 5430fd9b08819972f0a613bf3fa659da
|
environments/workspace/look/advanced_styling_field_option_font_size_description: 5430fd9b08819972f0a613bf3fa659da
|
||||||
environments/workspace/look/advanced_styling_field_option_label: 2767a5db32742073a01aac16488e93dc
|
environments/workspace/look/advanced_styling_field_option_label: 2767a5db32742073a01aac16488e93dc
|
||||||
@@ -2105,7 +2133,7 @@ checksums:
|
|||||||
environments/workspace/look/show_powered_by_formbricks: a0e96edadec8ef326423feccc9d06be7
|
environments/workspace/look/show_powered_by_formbricks: a0e96edadec8ef326423feccc9d06be7
|
||||||
environments/workspace/look/styling_updated_successfully: b8b74b50dde95abcd498633e9d0c891f
|
environments/workspace/look/styling_updated_successfully: b8b74b50dde95abcd498633e9d0c891f
|
||||||
environments/workspace/look/suggest_colors: ddc4543b416ab774007b10a3434343cd
|
environments/workspace/look/suggest_colors: ddc4543b416ab774007b10a3434343cd
|
||||||
environments/workspace/look/suggested_colors_applied_please_save: 226fa70af5efc8ffa0a3755909c8163e
|
environments/workspace/look/suggested_colors_applied_please_save: a440b8e29a327822a94d9bbf8c52e2ed
|
||||||
environments/workspace/look/theme: 21fe00b7a518089576fb83c08631107a
|
environments/workspace/look/theme: 21fe00b7a518089576fb83c08631107a
|
||||||
environments/workspace/look/theme_settings_description: 9fc45322818c3774ab4a44ea14d7836e
|
environments/workspace/look/theme_settings_description: 9fc45322818c3774ab4a44ea14d7836e
|
||||||
environments/workspace/tags/add: 87c4a663507f2bcbbf79934af8164e13
|
environments/workspace/tags/add: 87c4a663507f2bcbbf79934af8164e13
|
||||||
@@ -2165,7 +2193,7 @@ checksums:
|
|||||||
s/check_inbox_or_spam: c48ac1f7b76052881bb3b6d10615152d
|
s/check_inbox_or_spam: c48ac1f7b76052881bb3b6d10615152d
|
||||||
s/completed: 98a9cd97b409933edf1991e7d022bea9
|
s/completed: 98a9cd97b409933edf1991e7d022bea9
|
||||||
s/create_your_own: 27976ec69029d6dd52d146a9b5765bc6
|
s/create_your_own: 27976ec69029d6dd52d146a9b5765bc6
|
||||||
s/enter_pin: 1d902362d8063ca1442bebabaab5115b
|
s/enter_pin: 90bccdc2e51ee2550842287d1f02c999
|
||||||
s/just_curious: d5e5c40e97fcfdab563707ab0de10862
|
s/just_curious: d5e5c40e97fcfdab563707ab0de10862
|
||||||
s/link_invalid: bd63a73fa5eecad2dfd0687cfed02114
|
s/link_invalid: bd63a73fa5eecad2dfd0687cfed02114
|
||||||
s/paused: 65ec150837ca033f5a0fb5d4482e0e4b
|
s/paused: 65ec150837ca033f5a0fb5d4482e0e4b
|
||||||
@@ -2222,6 +2250,16 @@ checksums:
|
|||||||
templates/alignment_and_engagement_survey_question_4_headline: e36be56ce8aad1d0ca04939bea4e39b7
|
templates/alignment_and_engagement_survey_question_4_headline: e36be56ce8aad1d0ca04939bea4e39b7
|
||||||
templates/alignment_and_engagement_survey_question_4_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
templates/alignment_and_engagement_survey_question_4_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||||
templates/back: f541015a827e37cb3b1234e56bc2aa3c
|
templates/back: f541015a827e37cb3b1234e56bc2aa3c
|
||||||
|
templates/block_1: 5e1b4dce0cb70662441b663507a69454
|
||||||
|
templates/block_10: 09a42e99b34b45700e734730acfe37ed
|
||||||
|
templates/block_2: f50d8aab8b44f168a2ab00526d4f9a2c
|
||||||
|
templates/block_3: 78d84f8e4763a95710543c5368ce8a41
|
||||||
|
templates/block_4: 2c346374f245a6821940c061b855ac69
|
||||||
|
templates/block_5: 975abfc66e8e377478ff691a040dda0b
|
||||||
|
templates/block_6: 2bd10f1edb210243c5ab459c59e02d30
|
||||||
|
templates/block_7: 13f0f680c09c96081e125123ad2f6786
|
||||||
|
templates/block_8: 1be1b18e159e8c8d11d2fb1082ea5d98
|
||||||
|
templates/block_9: 2da3894d05e4415fa043ba18d11d60e2
|
||||||
templates/book_interview: 1cc9c72d1c088b28e5dfa5ec7d7b78c4
|
templates/book_interview: 1cc9c72d1c088b28e5dfa5ec7d7b78c4
|
||||||
templates/build_product_roadmap_description: 6ca163ed3b0095cedcbc11822a0d502a
|
templates/build_product_roadmap_description: 6ca163ed3b0095cedcbc11822a0d502a
|
||||||
templates/build_product_roadmap_name: 8c216b183c3539c0340ce87465a391cc
|
templates/build_product_roadmap_name: 8c216b183c3539c0340ce87465a391cc
|
||||||
@@ -2377,7 +2415,7 @@ checksums:
|
|||||||
templates/csat_question_3_choice_8: 6abbde1174a09d7b9d61de382f7f9677
|
templates/csat_question_3_choice_8: 6abbde1174a09d7b9d61de382f7f9677
|
||||||
templates/csat_question_3_choice_9: 4c3429e68c6fbd9a8808285838054e0c
|
templates/csat_question_3_choice_9: 4c3429e68c6fbd9a8808285838054e0c
|
||||||
templates/csat_question_3_headline: 8c6ffa4d4c0fef480bf22f61dbea5ce6
|
templates/csat_question_3_headline: 8c6ffa4d4c0fef480bf22f61dbea5ce6
|
||||||
templates/csat_question_3_subheader: 8169b8e0ff6958f3d617b397f991f7af
|
templates/csat_question_3_subheader: ed43eb9723166fd6a8d8e44356def97e
|
||||||
templates/csat_question_4_choice_1: 3af1ce39d4f133a26c4272c8245ded07
|
templates/csat_question_4_choice_1: 3af1ce39d4f133a26c4272c8245ded07
|
||||||
templates/csat_question_4_choice_2: 78f2c2b121ffbb4633452f7da4e9c338
|
templates/csat_question_4_choice_2: 78f2c2b121ffbb4633452f7da4e9c338
|
||||||
templates/csat_question_4_choice_3: 315f0d4c1497f69ef252d86e486fc715
|
templates/csat_question_4_choice_3: 315f0d4c1497f69ef252d86e486fc715
|
||||||
@@ -2429,7 +2467,6 @@ checksums:
|
|||||||
templates/csat_survey_question_3_headline: 25974b7f1692cad41908fe305830b6c0
|
templates/csat_survey_question_3_headline: 25974b7f1692cad41908fe305830b6c0
|
||||||
templates/csat_survey_question_3_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
templates/csat_survey_question_3_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||||
templates/cta_description: bc94a2ddc965b286a8677b0642696c7e
|
templates/cta_description: bc94a2ddc965b286a8677b0642696c7e
|
||||||
templates/custom_survey_block_1_name: 5e1b4dce0cb70662441b663507a69454
|
|
||||||
templates/custom_survey_description: 0492afdea2ef1bd683eaf48a2bad2caa
|
templates/custom_survey_description: 0492afdea2ef1bd683eaf48a2bad2caa
|
||||||
templates/custom_survey_name: 6fc756927ca9ea22c26368cccd64a67e
|
templates/custom_survey_name: 6fc756927ca9ea22c26368cccd64a67e
|
||||||
templates/custom_survey_question_1_headline: 0abf9d41e0b5c5567c3833fd63048398
|
templates/custom_survey_question_1_headline: 0abf9d41e0b5c5567c3833fd63048398
|
||||||
@@ -2832,6 +2869,9 @@ checksums:
|
|||||||
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
|
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
|
||||||
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
|
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
|
||||||
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
|
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
|
||||||
|
templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c
|
||||||
|
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||||
|
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
|
||||||
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
|
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
|
||||||
templates/prioritize_features_description: 1eae41fad0e3947f803d8539081e59ec
|
templates/prioritize_features_description: 1eae41fad0e3947f803d8539081e59ec
|
||||||
templates/prioritize_features_name: 4ca59ff1f9c319aaa68c3106d820fd6a
|
templates/prioritize_features_name: 4ca59ff1f9c319aaa68c3106d820fd6a
|
||||||
@@ -2901,7 +2941,7 @@ checksums:
|
|||||||
templates/professional_development_survey_question_2_choice_5: cc3b193ad5efcdd4f088745011d8b914
|
templates/professional_development_survey_question_2_choice_5: cc3b193ad5efcdd4f088745011d8b914
|
||||||
templates/professional_development_survey_question_2_choice_6: 79acaa6cd481262bea4e743a422529d2
|
templates/professional_development_survey_question_2_choice_6: 79acaa6cd481262bea4e743a422529d2
|
||||||
templates/professional_development_survey_question_2_headline: b561b69cd31ac95fbf8238534d5aadfc
|
templates/professional_development_survey_question_2_headline: b561b69cd31ac95fbf8238534d5aadfc
|
||||||
templates/professional_development_survey_question_2_subheader: a660423e16f78ad344493388602ac7a7
|
templates/professional_development_survey_question_2_subheader: ed43eb9723166fd6a8d8e44356def97e
|
||||||
templates/professional_development_survey_question_3_choice_1: ec580fd11a45779b039466f1e35eed2a
|
templates/professional_development_survey_question_3_choice_1: ec580fd11a45779b039466f1e35eed2a
|
||||||
templates/professional_development_survey_question_3_choice_2: 8c708225830b06df2d1141c536f2a0d6
|
templates/professional_development_survey_question_3_choice_2: 8c708225830b06df2d1141c536f2a0d6
|
||||||
templates/professional_development_survey_question_3_headline: 6cdb6730878966a48bab260aa5da9973
|
templates/professional_development_survey_question_3_headline: 6cdb6730878966a48bab260aa5da9973
|
||||||
@@ -3080,3 +3120,14 @@ checksums:
|
|||||||
templates/usability_question_9_headline: 5850229e97ae97698ce90b330ea49682
|
templates/usability_question_9_headline: 5850229e97ae97698ce90b330ea49682
|
||||||
templates/usability_rating_description: 8c4f3818fe830ae544611f816265f1a1
|
templates/usability_rating_description: 8c4f3818fe830ae544611f816265f1a1
|
||||||
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
|
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
|
||||||
|
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
|
||||||
|
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
|
||||||
|
workflows/follow_up_label: 8cafe669370271035aeac8e8cab0f123
|
||||||
|
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
|
||||||
|
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
|
||||||
|
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
|
||||||
|
workflows/placeholder: f5d943582bf25e8734930844e598457b
|
||||||
|
workflows/subheading: ebf5e3b3aeb85e13e843358cc5476f42
|
||||||
|
workflows/submit_button: 7a062f2de02ce60b1d73e510ff1ca094
|
||||||
|
workflows/thank_you_description: 7623c1ba4f059c8d9e68aae3360b20b1
|
||||||
|
workflows/thank_you_title: 07edd8c50685a52c0969d711df26d768
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
|
|||||||
|
|
||||||
export const SLACK_CLIENT_SECRET = env.SLACK_CLIENT_SECRET;
|
export const SLACK_CLIENT_SECRET = env.SLACK_CLIENT_SECRET;
|
||||||
export const SLACK_CLIENT_ID = env.SLACK_CLIENT_ID;
|
export const SLACK_CLIENT_ID = env.SLACK_CLIENT_ID;
|
||||||
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read`;
|
export const SLACK_REDIRECT_URI = `${WEBAPP_URL}/api/v1/integrations/slack/callback`;
|
||||||
|
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read&redirect_uri=${SLACK_REDIRECT_URI}`;
|
||||||
|
|
||||||
export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID;
|
export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID;
|
||||||
export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET;
|
export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET;
|
||||||
@@ -158,7 +159,7 @@ export const BREVO_LIST_ID = env.BREVO_LIST_ID;
|
|||||||
export const UNSPLASH_ACCESS_KEY = env.UNSPLASH_ACCESS_KEY;
|
export const UNSPLASH_ACCESS_KEY = env.UNSPLASH_ACCESS_KEY;
|
||||||
export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"];
|
export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"];
|
||||||
|
|
||||||
export const STRIPE_API_VERSION = "2024-06-20";
|
export const STRIPE_API_VERSION = "2026-02-25.clover";
|
||||||
|
|
||||||
// Maximum number of attribute classes allowed:
|
// Maximum number of attribute classes allowed:
|
||||||
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
|
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user