diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/actions.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/actions.ts index c1cab5731b..b32eb914eb 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/actions.ts @@ -2,6 +2,7 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@formbricks/lib/authOptions"; +import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { canUserAccessIntegration } from "@formbricks/lib/integration/auth"; import { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/integration/service"; import { AuthorizationError } from "@formbricks/types/errors"; @@ -11,6 +12,12 @@ export const createOrUpdateIntegrationAction = async ( environmentId: string, integrationData: TIntegrationInput ) => { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authenticated"); + + const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + return await createOrUpdateIntegration(environmentId, integrationData); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx index d338af976d..10c4294e5f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx @@ -20,6 +20,7 @@ import { TIntegrationAirtableTables, } from "@formbricks/types/integration/airtable"; import { TSurvey } from "@formbricks/types/surveys"; +import { AdditionalIntegrationSettings } from "@formbricks/ui/AdditionalIntegrationSettings"; import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert"; import { Button } from "@formbricks/ui/Button"; import { Checkbox } from "@formbricks/ui/Checkbox"; @@ -46,6 +47,8 @@ export type IntegrationModalInputs = { table: string; survey: string; questions: string[]; + includeHiddenFields: boolean; + includeMetadata: boolean; }; const NoBaseFoundError = () => { @@ -72,12 +75,24 @@ export const AddIntegrationModal = ({ const [tables, setTables] = useState([]); const [isLoading, setIsLoading] = useState(false); const { handleSubmit, control, watch, setValue, reset } = useForm(); + const [includeHiddenFields, setIncludeHiddenFields] = useState(false); + const [includeMetadata, setIncludeMetadata] = useState(false); + const airtableIntegrationData: TIntegrationAirtableInput = { + type: "airtable", + config: { + key: airtableIntegration?.config?.key, + data: airtableIntegration.config.data ?? [], + email: airtableIntegration?.config?.email, + }, + }; useEffect(() => { if (isEditMode) { const { index: _index, ...rest } = defaultData; reset(rest); fetchTable(defaultData.base); + setIncludeHiddenFields(defaultData.includeHiddenFields); + setIncludeMetadata(defaultData.includeMetadata); } else { reset(); } @@ -104,15 +119,6 @@ export const AddIntegrationModal = ({ throw new Error("Please select at least one question"); } - const airtableIntegrationData: TIntegrationAirtableInput = { - type: "airtable", - config: { - key: airtableIntegration?.config?.key, - data: airtableIntegration.config.data ?? [], - email: airtableIntegration?.config?.email, - }, - }; - const currentTable = tables.find((item) => item.id === data.table); const integrationData: TIntegrationAirtableConfigData = { surveyId: selectedSurvey.id, @@ -124,6 +130,8 @@ export const AddIntegrationModal = ({ baseId: data.base, tableId: data.table, tableName: currentTable?.name ?? "", + includeHiddenFields, + includeMetadata, }; if (isEditMode) { @@ -165,10 +173,10 @@ export const AddIntegrationModal = ({ const handleDelete = async (index: number) => { try { - const integrationCopy = { ...airtableIntegration }; - integrationCopy.config.data.splice(index, 1); + const integrationData = structuredClone(airtableIntegrationData); + integrationData.config.data.splice(index, 1); - await createOrUpdateIntegrationAction(environmentId, integrationCopy); + await createOrUpdateIntegrationAction(environmentId, integrationData); handleClose(); router.refresh(); @@ -280,42 +288,52 @@ export const AddIntegrationModal = ({ ) : null} {survey && selectedSurvey && ( -
- -
-
- {replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map( - (question) => ( - ( -
- -
- )} - /> - ) - )} +
+
+ +
+
+ {replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map( + (question) => ( + ( +
+ +
+ )} + /> + ) + )} +
+
)} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.tsx index f86172b7a8..804a28c9fb 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.tsx @@ -112,6 +112,8 @@ export const ManageIntegration = (props: ManageIntegrationProps) => { questions: data.questionIds, survey: data.surveyId, table: data.tableId, + includeHiddenFields: !!data.includeHiddenFields, + includeMetadata: !!data.includeMetadata, index, }); setIsModalOpen(true); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/actions.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/actions.ts index 0321bd5e4d..bec98e9510 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/actions.ts @@ -17,6 +17,9 @@ export async function getSpreadsheetNameByIdAction( const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); if (!isAuthorized) throw new AuthorizationError("Not authorized"); - - return await getSpreadsheetNameById(googleSheetIntegration, spreadsheetId); + const integrationData = structuredClone(googleSheetIntegration); + integrationData.config.data.forEach((data) => { + data.createdAt = new Date(data.createdAt); + }); + return await getSpreadsheetNameById(integrationData, spreadsheetId); } diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx index 443f290ed0..114c32d91b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx @@ -19,6 +19,7 @@ import { TIntegrationGoogleSheetsInput, } from "@formbricks/types/integration/googleSheet"; import { TSurvey } from "@formbricks/types/surveys"; +import { AdditionalIntegrationSettings } from "@formbricks/ui/AdditionalIntegrationSettings"; import { Button } from "@formbricks/ui/Button"; import { Checkbox } from "@formbricks/ui/Checkbox"; import { DropdownSelector } from "@formbricks/ui/DropdownSelector"; @@ -45,7 +46,7 @@ export const AddIntegrationModal = ({ selectedIntegration, attributeClasses, }: AddIntegrationModalProps) => { - const integrationData = { + const integrationData: TIntegrationGoogleSheetsConfigData = { spreadsheetId: "", spreadsheetName: "", surveyId: "", @@ -61,6 +62,8 @@ export const AddIntegrationModal = ({ const [spreadsheetUrl, setSpreadsheetUrl] = useState(""); const [isDeleting, setIsDeleting] = useState(false); const existingIntegrationData = googleSheetIntegration?.config?.data; + const [includeHiddenFields, setIncludeHiddenFields] = useState(false); + const [includeMetadata, setIncludeMetadata] = useState(false); const googleSheetIntegrationData: TIntegrationGoogleSheetsInput = { type: "googleSheets", config: { @@ -71,7 +74,7 @@ export const AddIntegrationModal = ({ }; useEffect(() => { - if (selectedSurvey) { + if (selectedSurvey && !selectedIntegration) { const questionIds = selectedSurvey.questions.map((question) => question.id); setSelectedQuestions(questionIds); } @@ -86,6 +89,8 @@ export const AddIntegrationModal = ({ })! ); setSelectedQuestions(selectedIntegration.questionIds); + setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields); + setIncludeMetadata(!!selectedIntegration.includeMetadata); return; } else { setSpreadsheetUrl(""); @@ -95,7 +100,7 @@ export const AddIntegrationModal = ({ const linkSheet = async () => { try { - if (isValidGoogleSheetsUrl(spreadsheetUrl)) { + if (!isValidGoogleSheetsUrl(spreadsheetUrl)) { throw new Error("Please enter a valid spreadsheet url"); } if (!selectedSurvey) { @@ -110,6 +115,7 @@ export const AddIntegrationModal = ({ environmentId, spreadsheetId ); + setIsLinkingSheet(true); integrationData.spreadsheetId = spreadsheetId; integrationData.spreadsheetName = spreadsheetName; @@ -121,6 +127,8 @@ export const AddIntegrationModal = ({ ? "All questions" : "Selected questions"; integrationData.createdAt = new Date(); + integrationData.includeHiddenFields = includeHiddenFields; + integrationData.includeMetadata = includeMetadata; if (selectedIntegration) { // update action googleSheetIntegrationData.config!.data[selectedIntegration.index] = integrationData; @@ -148,12 +156,16 @@ export const AddIntegrationModal = ({ }; const setOpenWithStates = (isOpen: boolean) => { + resetForm(); setOpen(isOpen); }; const resetForm = () => { + setSpreadsheetUrl(""); setIsLinkingSheet(false); setSelectedSurvey(null); + setIncludeHiddenFields(false); + setIncludeMetadata(false); }; const deleteLink = async () => { @@ -214,33 +226,41 @@ export const AddIntegrationModal = ({
{selectedSurvey && ( -
- -
-
- {replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map( - (question) => ( -
- -
- ) - )} +
+
+ +
+
+ {replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map( + (question) => ( +
+ +
+ ) + )} +
+
)}
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util.ts index 4b9b94b930..ac475804db 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util.ts +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util.ts @@ -14,7 +14,5 @@ export const constructGoogleSheetsUrl = (spreadsheetId: string): string => { }; export const isValidGoogleSheetsUrl = (url: string): boolean => { - // Regular expression to match Google Sheets URL format - const googleSheetsUrlRegex = /^https:\/\/docs\.google\.com\/spreadsheets\/d\/[a-zA-Z0-9-_]+\/?$/; - return googleSheetsUrlRegex.test(url); + return url.startsWith("https://docs.google.com/spreadsheets/d/"); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx index 442915169c..8ceda5fdd3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx @@ -121,11 +121,19 @@ export const AddIntegrationModal = ({ const hiddenFields = selectedSurvey?.hiddenFields.enabled ? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({ id: fId, - name: fId, + name: `Hidden field : ${fId}`, type: TSurveyQuestionTypeEnum.OpenText, })) || [] : []; - return [...questions, ...hiddenFields]; + const Metadata = [ + { + id: "metadata", + name: `Metadata`, + type: TSurveyQuestionTypeEnum.OpenText, + }, + ]; + + return [...questions, ...hiddenFields, ...Metadata]; // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedSurvey?.id]); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.tsx index bb67a2d218..1395b58690 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.tsx @@ -14,6 +14,7 @@ import { TIntegrationSlackInput, } from "@formbricks/types/integration/slack"; import { TSurvey } from "@formbricks/types/surveys"; +import { AdditionalIntegrationSettings } from "@formbricks/ui/AdditionalIntegrationSettings"; import { Button } from "@formbricks/ui/Button"; import { Checkbox } from "@formbricks/ui/Checkbox"; import { DropdownSelector } from "@formbricks/ui/DropdownSelector"; @@ -48,6 +49,8 @@ export const AddChannelMappingModal = ({ const [selectedSurvey, setSelectedSurvey] = useState(null); const [selectedChannel, setSelectedChannel] = useState(null); const [isDeleting, setIsDeleting] = useState(false); + const [includeHiddenFields, setIncludeHiddenFields] = useState(false); + const [includeMetadata, setIncludeMetadata] = useState(false); const existingIntegrationData = slackIntegration?.config?.data; const slackIntegrationData: TIntegrationSlackInput = { type: "slack", @@ -78,6 +81,8 @@ export const AddChannelMappingModal = ({ })! ); setSelectedQuestions(selectedIntegration.questionIds); + setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields); + setIncludeMetadata(!!selectedIntegration.includeMetadata); return; } resetForm(); @@ -107,6 +112,8 @@ export const AddChannelMappingModal = ({ ? "All questions" : "Selected questions", createdAt: new Date(), + includeHiddenFields, + includeMetadata, }; if (selectedIntegration) { // update action @@ -222,30 +229,40 @@ export const AddChannelMappingModal = ({
{selectedSurvey && (
- -
-
- {replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions?.map( - (question) => ( -
- -
- ) - )} +
+ +
+
+ {replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions?.map( + (question) => ( +
+ +
+ ) + )} +
+
)}
diff --git a/apps/web/app/api/pipeline/lib/handleIntegrations.ts b/apps/web/app/api/pipeline/lib/handleIntegrations.ts index ffc3254bab..1e839e601d 100644 --- a/apps/web/app/api/pipeline/lib/handleIntegrations.ts +++ b/apps/web/app/api/pipeline/lib/handleIntegrations.ts @@ -10,8 +10,42 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/googleSh import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion"; import { TIntegrationSlack } from "@formbricks/types/integration/slack"; import { TPipelineInput } from "@formbricks/types/pipelines"; +import { TResponseMeta } from "@formbricks/types/responses"; import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys"; +const convertMetaObjectToString = (metadata: TResponseMeta): string => { + let result: string[] = []; + if (metadata.source) result.push(`Source: ${metadata.source}`); + if (metadata.url) result.push(`URL: ${metadata.url}`); + if (metadata.userAgent?.browser) result.push(`Browser: ${metadata.userAgent.browser}`); + if (metadata.userAgent?.os) result.push(`OS: ${metadata.userAgent.os}`); + if (metadata.userAgent?.device) result.push(`Device: ${metadata.userAgent.device}`); + if (metadata.country) result.push(`Country: ${metadata.country}`); + if (metadata.action) result.push(`Action: ${metadata.action}`); + + // Join all the elements in the result array with a newline for formatting + return result.join("\n"); +}; + +const processDataForIntegration = async ( + data: TPipelineInput, + survey: TSurvey, + includeMetadata: boolean, + includeHiddenFields: boolean, + questionIds: string[] +): Promise => { + const ids = + includeHiddenFields && survey.hiddenFields.fieldIds + ? [...questionIds, ...survey.hiddenFields.fieldIds] + : questionIds; + const values = await extractResponses(data, ids, survey); + if (includeMetadata) { + values[0].push(convertMetaObjectToString(data.response.meta)); + values[1].push("Metadata"); + } + return values; +}; + export const handleIntegrations = async ( integrations: TIntegration[], data: TPipelineInput, @@ -43,8 +77,13 @@ const handleAirtableIntegration = async ( if (integration.config.data.length > 0) { for (const element of integration.config.data) { if (element.surveyId === data.surveyId) { - const values = await extractResponses(data, element.questionIds as string[], survey); - + const values = await processDataForIntegration( + data, + survey, + !!element.includeMetadata, + !!element.includeHiddenFields, + element.questionIds + ); await airtableWriteData(integration.config.key, element, values); } } @@ -59,11 +98,18 @@ const handleGoogleSheetsIntegration = async ( if (integration.config.data.length > 0) { for (const element of integration.config.data) { if (element.surveyId === data.surveyId) { - const values = await extractResponses(data, element.questionIds, survey); + const values = await processDataForIntegration( + data, + survey, + !!element.includeMetadata, + !!element.includeHiddenFields, + element.questionIds + ); const integrationData = structuredClone(integration); integrationData.config.data.forEach((data) => { data.createdAt = new Date(data.createdAt); }); + await writeData(integrationData, element.spreadsheetId, values); } } @@ -78,7 +124,13 @@ const handleSlackIntegration = async ( if (integration.config.data.length > 0) { for (const element of integration.config.data) { if (element.surveyId === data.surveyId) { - const values = await extractResponses(data, element.questionIds as string[], survey); + const values = await processDataForIntegration( + data, + survey, + !!element.includeMetadata, + !!element.includeHiddenFields, + element.questionIds + ); await writeDataToSlack(integration.config.key, element.channelId, values, survey?.name); } } @@ -86,7 +138,7 @@ const handleSlackIntegration = async ( }; const extractResponses = async ( - data: TPipelineInput, + pipelineData: TPipelineInput, questionIds: string[], survey: TSurvey ): Promise => { @@ -94,12 +146,18 @@ const extractResponses = async ( const questions: string[] = []; for (const questionId of questionIds) { + //check for hidden field Ids + if (survey.hiddenFields.fieldIds?.includes(questionId)) { + responses.push(processResponseData(pipelineData.response.data[questionId])); + questions.push(questionId); + continue; + } const question = survey?.questions.find((q) => q.id === questionId); if (!question) { continue; } - const responseValue = data.response.data[questionId]; + const responseValue = pipelineData.response.data[questionId]; if (responseValue !== undefined) { let answer: typeof responseValue; @@ -162,11 +220,16 @@ const buildNotionPayloadProperties = ( }); mapping.forEach((map) => { - const value = responses[map.question.id]; - - properties[map.column.name] = { - [map.column.type]: getValue(map.column.type, processResponseData(value)), - }; + if (map.question.id === "metadata") { + properties[map.column.name] = { + [map.column.type]: getValue(map.column.type, convertMetaObjectToString(data.response.meta)), + }; + } else { + const value = responses[map.question.id]; + properties[map.column.name] = { + [map.column.type]: getValue(map.column.type, processResponseData(value)), + }; + } }); return properties; diff --git a/packages/types/integration/sharedTypes.ts b/packages/types/integration/sharedTypes.ts index 069789e94b..b8369ac81d 100644 --- a/packages/types/integration/sharedTypes.ts +++ b/packages/types/integration/sharedTypes.ts @@ -10,6 +10,8 @@ export const ZIntegrationBase = z.object({ export const ZIntegrationBaseSurveyData = z.object({ createdAt: z.date(), questionIds: z.array(z.string()), + includeHiddenFields: z.boolean().optional(), + includeMetadata: z.boolean().optional(), questions: z.string(), surveyId: z.string(), surveyName: z.string(), diff --git a/packages/ui/AdditionalIntegrationSettings/index.tsx b/packages/ui/AdditionalIntegrationSettings/index.tsx new file mode 100644 index 0000000000..c41baa06e3 --- /dev/null +++ b/packages/ui/AdditionalIntegrationSettings/index.tsx @@ -0,0 +1,54 @@ +import { Checkbox } from "../Checkbox"; +import { Label } from "../Label"; + +interface AdditionalIntegrationSettingsProps { + includeHiddenFields: boolean; + includeMetadata: boolean; + setIncludeHiddenFields: (includeHiddenFields: boolean) => void; + setIncludeMetadata: (includeHiddenFields: boolean) => void; +} + +export const AdditionalIntegrationSettings = ({ + includeHiddenFields, + includeMetadata, + setIncludeHiddenFields, + setIncludeMetadata, +}: AdditionalIntegrationSettingsProps) => { + return ( +
+ +
+
+ +
+
+ +
+
+
+ ); +}; diff --git a/packages/ui/DropdownSelector/index.tsx b/packages/ui/DropdownSelector/index.tsx index f4e6defb38..410a93c662 100644 --- a/packages/ui/DropdownSelector/index.tsx +++ b/packages/ui/DropdownSelector/index.tsx @@ -53,7 +53,7 @@ export const DropdownSelector = ({ className="z-50 max-h-64 min-w-[220px] max-w-[90%] overflow-auto rounded-md bg-white text-sm text-slate-800 shadow-md" align="start"> {items - .sort((a, b) => a.name.localeCompare(b.name)) + .sort((a, b) => a.name?.localeCompare(b.name)) .map((item) => (