mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-20 10:09:20 -06:00
Compare commits
2 Commits
fix/adds-w
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
219883266c | ||
|
|
55fc2b2bc8 |
42
.github/workflows/translation-check.yml
vendored
42
.github/workflows/translation-check.yml
vendored
@@ -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."
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user