Compare commits

..

2 Commits

Author SHA1 Message Date
Anshuman Pandey
219883266c fix: add bool support (#7323) 2026-02-20 15:30:40 +00:00
Theodór Tómas
55fc2b2bc8 chore: removing i18n from pre-commit hook (#7318) 2026-02-20 10:48:44 +00:00
10 changed files with 73 additions and 127 deletions

View File

@@ -6,19 +6,9 @@ permissions:
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
push:
branches:
- main
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
jobs:
validate-translations:
@@ -33,30 +23,38 @@ jobs:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check for relevant changes
id: changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
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
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
- name: Install pnpm
if: steps.changes.outputs.translations == 'true'
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys
run: |
echo ""
echo "🔍 Validating translation keys..."
echo ""
pnpm run scan-translations
if: steps.changes.outputs.translations == 'true'
run: pnpm run scan-translations
- name: Summary
if: success()
run: |
echo ""
echo "✅ Translation validation completed successfully!"
echo ""
- name: Skip (no translation-related changes)
if: steps.changes.outputs.translations != 'true'
run: echo "No translation-related files changed — skipping validation."

View File

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

View File

@@ -54,7 +54,6 @@ export const prepareNewSDKAttributeForStorage = (
};
const handleStringType = (value: TRawValue): TAttributeStorageColumns => {
// String type - only use value column
let stringValue: string;
if (value instanceof Date) {

View File

@@ -437,4 +437,22 @@ describe("updateAttributes", () => {
expect(result.success).toBe(true);
expect(result.messages).toContainEqual({ code: "email_or_userid_required", params: {} });
});
test("coerces boolean attribute values to strings", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { name: true, email: "john@example.com" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(result.success).toBe(true);
expect(prisma.$transaction).toHaveBeenCalled();
const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0];
// Both name (coerced from boolean) and email should be upserted
expect(transactionCall).toHaveLength(2);
});
});

View File

@@ -130,7 +130,12 @@ export const updateAttributes = async (
const messages: TAttributeUpdateMessage[] = [];
const errors: TAttributeUpdateMessage[] = [];
// Convert email and userId to strings for lookup (they should always be strings, but handle numbers gracefully)
// Coerce boolean values to strings (SDK may send booleans for string attributes)
const coercedAttributes: Record<string, string | number> = {};
for (const [key, value] of Object.entries(contactAttributesParam)) {
coercedAttributes[key] = typeof value === "boolean" ? String(value) : value;
}
const emailValue =
contactAttributesParam.email === null || contactAttributesParam.email === undefined
? null
@@ -154,7 +159,7 @@ export const updateAttributes = async (
const userIdExists = !!existingUserIdAttribute;
// Remove email and/or userId from attributes if they already exist on another contact
let contactAttributes = { ...contactAttributesParam };
let contactAttributes = { ...coercedAttributes };
// Determine what the final email and userId values will be after this update
// Only consider a value as "submitted" if it was explicitly included in the attributes

View File

@@ -2,7 +2,6 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { generateWebhookSecret } from "@/lib/crypto";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -25,7 +24,6 @@ import { ZWebhookInput } from "@/modules/integrations/webhooks/types/webhooks";
const ZCreateWebhookAction = z.object({
environmentId: ZId,
webhookInput: ZWebhookInput,
webhookSecret: z.string().optional(),
});
export const createWebhookAction = authenticatedActionClient.schema(ZCreateWebhookAction).action(
@@ -49,11 +47,7 @@ export const createWebhookAction = authenticatedActionClient.schema(ZCreateWebho
},
],
});
const webhook = await createWebhook(
parsedInput.environmentId,
parsedInput.webhookInput,
parsedInput.webhookSecret
);
const webhook = await createWebhook(parsedInput.environmentId, parsedInput.webhookInput);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.newObject = parsedInput.webhookInput;
return webhook;
@@ -137,23 +131,10 @@ export const updateWebhookAction = authenticatedActionClient.schema(ZUpdateWebho
const ZTestEndpointAction = z.object({
url: z.string(),
webhookId: ZId.optional(),
});
export const testEndpointAction = authenticatedActionClient
.schema(ZTestEndpointAction)
.action(async ({ parsedInput }) => {
let secret: string | undefined;
if (parsedInput.webhookId) {
const webhookResult = await getWebhook(parsedInput.webhookId);
if (webhookResult.ok) {
secret = webhookResult.data.secret ?? undefined;
}
} else {
secret = generateWebhookSecret();
}
await testEndpoint(parsedInput.url, secret);
return { success: true, secret };
return testEndpoint(parsedInput.url);
});

View File

@@ -54,14 +54,12 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
const [creatingWebhook, setCreatingWebhook] = useState(false);
const [createdWebhook, setCreatedWebhook] = useState<Webhook | null>(null);
const handleTestEndpoint = async (
sendSuccessToast: boolean
): Promise<{ success: boolean; secret?: string }> => {
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
try {
const { valid, error } = validWebHookURL(testEndpointInput);
if (!valid) {
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
return { success: false };
return;
}
setHittingEndpoint(true);
const testEndpointActionResult = await testEndpointAction({ url: testEndpointInput });
@@ -72,7 +70,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
setHittingEndpoint(false);
if (sendSuccessToast) toast.success(t("environments.integrations.webhooks.endpoint_pinged"));
setEndpointAccessible(true);
return testEndpointActionResult.data;
return true;
} catch (err) {
setHittingEndpoint(false);
toast.error(
@@ -85,7 +83,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
);
console.error(t("environments.integrations.webhooks.webhook_test_failed_due_to"), err.message);
setEndpointAccessible(false);
return { success: false };
return false;
}
};
@@ -129,8 +127,8 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
throw new Error(t("environments.integrations.webhooks.discord_webhook_not_supported"));
}
const testResult = await handleTestEndpoint(false);
if (!testResult.success) return;
const endpointHitSuccessfully = await handleTestEndpoint(false);
if (!endpointHitSuccessfully) return;
const updatedData: TWebhookInput = {
name: data.name,
@@ -143,7 +141,6 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
const createWebhookActionResult = await createWebhookAction({
environmentId,
webhookInput: updatedData,
webhookSecret: testResult.secret,
});
if (createWebhookActionResult?.data) {
router.refresh();

View File

@@ -58,19 +58,16 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
setTimeout(() => setCopied(false), 2000);
};
const handleTestEndpoint = async (sendSuccessToast: boolean): Promise<boolean> => {
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
try {
const { valid, error } = validWebHookURL(testEndpointInput);
if (!valid) {
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
return false;
return;
}
setHittingEndpoint(true);
const testEndpointActionResult = await testEndpointAction({
url: testEndpointInput,
webhookId: webhook.id,
});
if (!testEndpointActionResult?.data?.success) {
const testEndpointActionResult = await testEndpointAction({ url: testEndpointInput });
if (!testEndpointActionResult?.data) {
const errorMessage = getFormattedErrorMessage(testEndpointActionResult);
throw new Error(errorMessage);
}
@@ -223,7 +220,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 transform"
className="absolute top-1/2 right-3 -translate-y-1/2 transform"
onClick={() => setShowSecret(!showSecret)}>
{showSecret ? (
<EyeOff className="h-5 w-5 text-slate-400" />

View File

@@ -61,23 +61,19 @@ export const deleteWebhook = async (id: string): Promise<boolean> => {
}
};
export const createWebhook = async (
environmentId: string,
webhookInput: TWebhookInput,
secret?: string
): Promise<Webhook> => {
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
try {
if (isDiscordWebhook(webhookInput.url)) {
throw new UnknownError("Discord webhooks are currently not supported.");
}
const signingSecret = secret ?? generateWebhookSecret();
const secret = generateWebhookSecret();
const webhook = await prisma.webhook.create({
data: {
...webhookInput,
surveyIds: webhookInput.surveyIds || [],
secret: signingSecret,
secret,
environment: {
connect: {
id: environmentId,
@@ -122,7 +118,7 @@ export const getWebhooks = async (environmentId: string): Promise<Webhook[]> =>
}
};
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
export const testEndpoint = async (url: string): Promise<boolean> => {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
@@ -135,25 +131,19 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
const webhookTimestamp = Math.floor(Date.now() / 1000);
const body = JSON.stringify({ event: "testEndpoint" });
const requestHeaders: Record<string, string> = {
"Content-Type": "application/json",
"webhook-id": webhookMessageId,
"webhook-timestamp": webhookTimestamp.toString(),
};
if (secret) {
requestHeaders["webhook-signature"] = generateStandardWebhookSignature(
webhookMessageId,
webhookTimestamp,
body,
secret
);
}
// Generate a temporary test secret and signature for consistency with actual webhooks
const testSecret = generateWebhookSecret();
const signature = generateStandardWebhookSignature(webhookMessageId, webhookTimestamp, body, testSecret);
const response = await fetch(url, {
method: "POST",
body,
headers: requestHeaders,
headers: {
"Content-Type": "application/json",
"webhook-id": webhookMessageId,
"webhook-timestamp": webhookTimestamp.toString(),
"webhook-signature": signature,
},
signal: controller.signal,
});
clearTimeout(timeout);

View File

@@ -16,5 +16,5 @@ export type TContactAttribute = z.infer<typeof ZContactAttribute>;
export const ZContactAttributes = z.record(z.string());
export type TContactAttributes = z.infer<typeof ZContactAttributes>;
export const ZContactAttributesInput = z.record(z.union([z.string(), z.number()]));
export const ZContactAttributesInput = z.record(z.union([z.string(), z.number(), z.boolean()]));
export type TContactAttributesInput = z.infer<typeof ZContactAttributesInput>;