mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-25 17:40:37 -05:00
Compare commits
138 Commits
chore/test
...
fix/block-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5330477678 | ||
|
|
f654ba6f83 | ||
|
|
4e3dcae02d | ||
|
|
f141c53c68 | ||
|
|
0622407772 | ||
|
|
a6c0b6bbfc | ||
|
|
527f8fd0d5 | ||
|
|
7a0da3a3e4 | ||
|
|
1ce8595bfd | ||
|
|
5113a4bd2c | ||
|
|
4430ed0d8d | ||
|
|
ca15d9645d | ||
|
|
7306f60aff | ||
|
|
322a1b4e57 | ||
|
|
cd40401ace | ||
|
|
86cc8fb8ff | ||
|
|
9bb487ab57 | ||
|
|
39064f9d2c | ||
|
|
6a6e213b3e | ||
|
|
ee56cc10e7 | ||
|
|
7c47299775 | ||
|
|
92f4f04f7c | ||
|
|
e072a0e889 | ||
|
|
4723a428e7 | ||
|
|
d7692a1b76 | ||
|
|
a08f2db40c | ||
|
|
48eb4fe705 | ||
|
|
26a2d50d45 | ||
|
|
da5d9e27e1 | ||
|
|
8e2934d7bb | ||
|
|
799b86801d | ||
|
|
9d77b808d0 | ||
|
|
007d996870 | ||
|
|
9f59d7a967 | ||
|
|
b9c647ef62 | ||
|
|
7770d43f9a | ||
|
|
615aa6aaad | ||
|
|
f57ca755f4 | ||
|
|
fd37b978c0 | ||
|
|
d40ce9ce84 | ||
|
|
21c63bc400 | ||
|
|
77722aa638 | ||
|
|
2a9897370e | ||
|
|
5e85347bf5 | ||
|
|
e4a9d28b4b | ||
|
|
b79703f87e | ||
|
|
567cc4b893 | ||
|
|
d9b37496fc | ||
|
|
87a06c846a | ||
|
|
c5e02a597d | ||
|
|
1fec0ca7a6 | ||
|
|
ae165eac87 | ||
|
|
7de5fdc383 | ||
|
|
3e61d31041 | ||
|
|
d7e537f699 | ||
|
|
1e6c7609b6 | ||
|
|
59438d9afe | ||
|
|
e234ed78cf | ||
|
|
74b168d727 | ||
|
|
2ed2da61cd | ||
|
|
729f269d4e | ||
|
|
41776d0001 | ||
|
|
a2a6870a21 | ||
|
|
3ab62968e5 | ||
|
|
d4f7f0f35d | ||
|
|
a10cd0cb47 | ||
|
|
45100673f1 | ||
|
|
35f53769a5 | ||
|
|
22ad78a187 | ||
|
|
67076c4b4c | ||
|
|
74bfeb132e | ||
|
|
562b4047ae | ||
|
|
4791018546 | ||
|
|
be1e546729 | ||
|
|
5bad0da477 | ||
|
|
9c776c5e4e | ||
|
|
c50b46f715 | ||
|
|
ce0a0573be | ||
|
|
3e27143ab1 | ||
|
|
018e2883ff | ||
|
|
85fb7ca956 | ||
|
|
2258699156 | ||
|
|
b1a7b929bd | ||
|
|
fded9a3bad | ||
|
|
4d84468269 | ||
|
|
e6e010e801 | ||
|
|
8ced882406 | ||
|
|
f7d462cc7f | ||
|
|
f3d679d087 | ||
|
|
c79a600efc | ||
|
|
7a8da3b84b | ||
|
|
4b2d48397d | ||
|
|
3ea81dc7c1 | ||
|
|
d9b6b550a9 | ||
|
|
56a6ba08ba | ||
|
|
1ba55ff66c | ||
|
|
0cf621d76c | ||
|
|
3dc615fdc0 | ||
|
|
7157b17901 | ||
|
|
82c26941e4 | ||
|
|
591d5fa3d4 | ||
|
|
211bca1bd8 | ||
|
|
5a20839c5b | ||
|
|
85743bd3d0 | ||
|
|
335ec02361 | ||
|
|
7918523957 | ||
|
|
3b5fe4cb94 | ||
|
|
6bbd5ec7ef | ||
|
|
c9542dcf79 | ||
|
|
4277a9dc34 | ||
|
|
b1da63e47d | ||
|
|
8c05154a86 | ||
|
|
45122de652 | ||
|
|
2180bf98ba | ||
|
|
2d4a94721b | ||
|
|
b2b97c8bed | ||
|
|
f349f7199d | ||
|
|
e7d8803a13 | ||
|
|
53a9b218bc | ||
|
|
c618e7d473 | ||
|
|
3d0f703ae1 | ||
|
|
33eadaaa7b | ||
|
|
452617529c | ||
|
|
5951eea618 | ||
|
|
e314feb416 | ||
|
|
0910b0f1a7 | ||
|
|
10ba42eb31 | ||
|
|
04f1e17e23 | ||
|
|
4642cc60c9 | ||
|
|
49fa5c587c | ||
|
|
4f9b48b5e5 | ||
|
|
80789327d0 | ||
|
|
38108a32d1 | ||
|
|
ce4b64da0e | ||
|
|
9790b071d7 | ||
|
|
1f5ba0e60e | ||
|
|
b502bbc91e | ||
|
|
6772ac7c20 |
@@ -1,272 +0,0 @@
|
||||
import { IntegrationType } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { getCacheService } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { sendTelemetryEvents } from "./telemetry";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/cache");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
organization: {
|
||||
findFirst: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
user: { count: vi.fn() },
|
||||
team: { count: vi.fn() },
|
||||
project: { count: vi.fn() },
|
||||
survey: { count: vi.fn() },
|
||||
response: {
|
||||
count: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
display: { count: vi.fn() },
|
||||
contact: { count: vi.fn() },
|
||||
segment: { count: vi.fn() },
|
||||
integration: { findMany: vi.fn() },
|
||||
account: { findMany: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
S3_BUCKET_NAME: "my-bucket",
|
||||
PROMETHEUS_ENABLED: true,
|
||||
RECAPTCHA_SITE_KEY: "site-key",
|
||||
RECAPTCHA_SECRET_KEY: "secret-key",
|
||||
GITHUB_ID: "github-id",
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const mockCacheService = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
tryLock: vi.fn(),
|
||||
del: vi.fn(),
|
||||
};
|
||||
|
||||
describe("sendTelemetryEvents", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.useFakeTimers();
|
||||
// Set a fixed time far in the past to ensure we can always send telemetry
|
||||
vi.setSystemTime(new Date("2024-01-01T00:00:00.000Z"));
|
||||
|
||||
// Setup default cache behavior
|
||||
vi.mocked(getCacheService).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockCacheService as any,
|
||||
});
|
||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
|
||||
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
|
||||
mockCacheService.get.mockResolvedValue({ ok: true, data: null }); // No last sent time
|
||||
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
|
||||
|
||||
// Setup default prisma behavior
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
|
||||
id: "org-123",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
} as any);
|
||||
|
||||
// Mock raw SQL query for counts (batched query)
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{
|
||||
organizationCount: BigInt(1),
|
||||
userCount: BigInt(5),
|
||||
teamCount: BigInt(2),
|
||||
projectCount: BigInt(3),
|
||||
surveyCount: BigInt(10),
|
||||
inProgressSurveyCount: BigInt(4),
|
||||
completedSurveyCount: BigInt(6),
|
||||
responseCountAllTime: BigInt(100),
|
||||
responseCountSinceLastUpdate: BigInt(10),
|
||||
displayCount: BigInt(50),
|
||||
contactCount: BigInt(20),
|
||||
segmentCount: BigInt(4),
|
||||
newestResponseAt: new Date("2024-01-01T00:00:00.000Z"),
|
||||
},
|
||||
] as any);
|
||||
|
||||
// Mock other queries
|
||||
vi.mocked(prisma.integration.findMany).mockResolvedValue([{ type: IntegrationType.notion }] as any);
|
||||
vi.mocked(prisma.account.findMany).mockResolvedValue([{ provider: "github" }] as any);
|
||||
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("should send telemetry successfully when conditions are met", async () => {
|
||||
await sendTelemetryEvents();
|
||||
|
||||
// Check lock acquisition
|
||||
expect(mockCacheService.tryLock).toHaveBeenCalledWith(
|
||||
"telemetry_lock",
|
||||
"locked",
|
||||
60 * 1000 // 1 minute TTL
|
||||
);
|
||||
|
||||
// Check data gathering
|
||||
expect(prisma.organization.findFirst).toHaveBeenCalled();
|
||||
expect(prisma.$queryRaw).toHaveBeenCalled();
|
||||
|
||||
// Check fetch call
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(payload.organizationCount).toBe(1);
|
||||
expect(payload.userCount).toBe(5);
|
||||
expect(payload.integrations.notion).toBe(true);
|
||||
expect(payload.sso.github).toBe(true);
|
||||
|
||||
// Check cache update (no TTL parameter)
|
||||
expect(mockCacheService.set).toHaveBeenCalledWith("telemetry_last_sent_ts", expect.any(String));
|
||||
|
||||
// Check lock release
|
||||
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
|
||||
});
|
||||
|
||||
test("should skip if in-memory check fails", async () => {
|
||||
// Run once to set nextTelemetryCheck
|
||||
await sendTelemetryEvents();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Run again immediately (should fail in-memory check)
|
||||
await sendTelemetryEvents();
|
||||
|
||||
expect(getCacheService).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should skip if Redis last sent time is recent", async () => {
|
||||
// Mock last sent time as recent
|
||||
const recentTime = Date.now() - 1000 * 60 * 60; // 1 hour ago
|
||||
mockCacheService.get.mockResolvedValue({ ok: true, data: String(recentTime) });
|
||||
|
||||
await sendTelemetryEvents();
|
||||
|
||||
expect(mockCacheService.tryLock).not.toHaveBeenCalled(); // No lock attempt
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should skip if lock cannot be acquired", async () => {
|
||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: false }); // Lock not acquired
|
||||
|
||||
await sendTelemetryEvents();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mockCacheService.del).not.toHaveBeenCalled(); // Shouldn't try to delete lock we didn't acquire
|
||||
});
|
||||
|
||||
test("should handle cache service failure gracefully", async () => {
|
||||
vi.mocked(getCacheService).mockResolvedValue({
|
||||
ok: false,
|
||||
error: new Error("Cache error"),
|
||||
} as any);
|
||||
|
||||
await sendTelemetryEvents();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
// Should verify that nextTelemetryCheck was updated, but it's a module variable.
|
||||
// We can infer it by running again and checking calls
|
||||
vi.clearAllMocks();
|
||||
await sendTelemetryEvents();
|
||||
expect(getCacheService).not.toHaveBeenCalled(); // Should be blocked by in-memory check
|
||||
});
|
||||
|
||||
test("should handle telemetry send failure and apply cooldown", async () => {
|
||||
// Reset module to clear nextTelemetryCheck state from previous tests
|
||||
vi.resetModules();
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
// Ensure we can acquire lock by setting last sent time far in the past
|
||||
const oldTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
|
||||
mockCacheService.get.mockResolvedValue({ ok: true, data: String(oldTime) });
|
||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
|
||||
|
||||
// Make fetch fail to trigger the catch block
|
||||
const networkError = new Error("Network error");
|
||||
fetchMock.mockRejectedValue(networkError);
|
||||
|
||||
await freshSendTelemetryEvents();
|
||||
|
||||
// Verify lock was acquired
|
||||
expect(mockCacheService.tryLock).toHaveBeenCalledWith("telemetry_lock", "locked", 60 * 1000);
|
||||
|
||||
// The error should be caught in the inner catch block
|
||||
// The actual implementation logs as warning, not error
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: networkError,
|
||||
message: "Network error",
|
||||
}),
|
||||
"Failed to send telemetry - applying 1h cooldown"
|
||||
);
|
||||
|
||||
// Lock should be released in finally block
|
||||
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
|
||||
|
||||
// Cache should not be updated on failure
|
||||
expect(mockCacheService.set).not.toHaveBeenCalled();
|
||||
|
||||
// Verify cooldown: run again immediately (should be blocked by in-memory check)
|
||||
vi.clearAllMocks();
|
||||
mockCacheService.get.mockResolvedValue({ ok: true, data: null });
|
||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true });
|
||||
await freshSendTelemetryEvents();
|
||||
expect(getCacheService).not.toHaveBeenCalled(); // Should be blocked by in-memory check
|
||||
});
|
||||
|
||||
test("should skip if no organization exists", async () => {
|
||||
// Reset module to clear nextTelemetryCheck state from previous tests
|
||||
vi.resetModules();
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
// Ensure we can acquire lock by setting last sent time far in the past
|
||||
const oldTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
|
||||
|
||||
// Re-setup mocks after resetModules
|
||||
vi.mocked(getCacheService).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockCacheService as any,
|
||||
});
|
||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
|
||||
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
|
||||
mockCacheService.get.mockResolvedValue({ ok: true, data: String(oldTime) });
|
||||
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
|
||||
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
|
||||
|
||||
await freshSendTelemetryEvents();
|
||||
|
||||
// sendTelemetry returns early when no org exists
|
||||
// Since it returns (not throws), the try block completes successfully
|
||||
// Then cache.set is called, and finally block executes
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
// Verify lock was acquired (prerequisite for finally block to execute)
|
||||
expect(mockCacheService.tryLock).toHaveBeenCalledWith("telemetry_lock", "locked", 60 * 1000);
|
||||
|
||||
// Lock should be released in finally block
|
||||
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
|
||||
|
||||
// Note: The current implementation calls cache.set even when no org exists
|
||||
// This might be a bug, but we test the actual behavior
|
||||
expect(mockCacheService.set).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,273 +0,0 @@
|
||||
import { IntegrationType } from "@prisma/client";
|
||||
import { createHash } from "node:crypto";
|
||||
import { type CacheKey, getCacheService } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { env } from "@/lib/env";
|
||||
import packageJson from "@/package.json";
|
||||
|
||||
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const TELEMETRY_LOCK_KEY = "telemetry_lock" as CacheKey;
|
||||
const TELEMETRY_LAST_SENT_KEY = "telemetry_last_sent_ts" as CacheKey;
|
||||
|
||||
/**
|
||||
* In-memory timestamp for the next telemetry check.
|
||||
* This is a fast, process-local check to avoid unnecessary Redis calls.
|
||||
* Updated after each check to prevent redundant executions.
|
||||
*/
|
||||
let nextTelemetryCheck = 0;
|
||||
|
||||
/**
|
||||
* Sends telemetry events to Formbricks Enterprise endpoint.
|
||||
* Uses a three-layer check system to prevent duplicate submissions:
|
||||
* 1. In-memory check (fast, process-local)
|
||||
* 2. Redis check (shared across instances, persists across restarts)
|
||||
* 3. Distributed lock (prevents concurrent execution in multi-instance deployments)
|
||||
*/
|
||||
export const sendTelemetryEvents = async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// ============================================================
|
||||
// CHECK 1: In-Memory Check (Fast Path)
|
||||
// ============================================================
|
||||
// Purpose: Quick process-local check to avoid Redis calls if we recently checked.
|
||||
// How it works: If current time is before nextTelemetryCheck, skip entirely.
|
||||
// This is updated after each successful check or failure to prevent spam.
|
||||
if (now < nextTelemetryCheck) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHECK 2: Redis Check (Shared State)
|
||||
// ============================================================
|
||||
// Purpose: Check if telemetry was sent recently by ANY instance (shared across cluster).
|
||||
// This persists across restarts and works in multi-instance deployments.
|
||||
|
||||
const cacheServiceResult = await getCacheService();
|
||||
if (!cacheServiceResult.ok) {
|
||||
// Redis unavailable: Fallback to in-memory cooldown to avoid spamming.
|
||||
// Wait 1 hour before trying again. This prevents hammering Redis when it's down.
|
||||
nextTelemetryCheck = now + 60 * 60 * 1000;
|
||||
return;
|
||||
}
|
||||
const cache = cacheServiceResult.data;
|
||||
|
||||
// Get the timestamp of when telemetry was last sent (from any instance).
|
||||
const lastSentResult = await cache.get(TELEMETRY_LAST_SENT_KEY);
|
||||
const lastSentStr = lastSentResult.ok && lastSentResult.data ? (lastSentResult.data as string) : null;
|
||||
const lastSent = lastSentStr ? Number.parseInt(lastSentStr, 10) : 0;
|
||||
|
||||
// If less than 24 hours have passed since last telemetry, skip.
|
||||
// Update in-memory check to match remaining time for fast-path optimization.
|
||||
if (now - lastSent < TELEMETRY_INTERVAL_MS) {
|
||||
nextTelemetryCheck = lastSent + TELEMETRY_INTERVAL_MS;
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHECK 3: Distributed Lock (Prevent Concurrent Execution)
|
||||
// ============================================================
|
||||
// Purpose: Ensure only ONE instance executes telemetry at a time in a cluster.
|
||||
// How it works:
|
||||
// - Uses Redis SET NX (only set if not exists) for atomic lock acquisition
|
||||
// - Lock expires after 1 minute (TTL) to prevent deadlocks if instance crashes
|
||||
// - If lock exists, another instance is already running telemetry, so we exit
|
||||
// - Lock is released in finally block after telemetry completes or fails
|
||||
const lockResult = await cache.tryLock(TELEMETRY_LOCK_KEY, "locked", 60 * 1000); // 1 minute TTL
|
||||
|
||||
if (!lockResult.ok || !lockResult.data) {
|
||||
// Lock acquisition failed or already held by another instance.
|
||||
// Exit silently - the other instance will handle telemetry.
|
||||
// No need to update nextTelemetryCheck here since we didn't execute.
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EXECUTION: Send Telemetry
|
||||
// ============================================================
|
||||
// We've passed all checks and acquired the lock. Now execute telemetry.
|
||||
try {
|
||||
await sendTelemetry(lastSent);
|
||||
|
||||
// Success: Update Redis with current timestamp so other instances know telemetry was sent.
|
||||
// No TTL - persists indefinitely to support low-volume instances (responses every few days/weeks).
|
||||
await cache.set(TELEMETRY_LAST_SENT_KEY, now.toString());
|
||||
|
||||
// Update in-memory check to prevent this instance from checking again for 24h.
|
||||
nextTelemetryCheck = now + TELEMETRY_INTERVAL_MS;
|
||||
} catch (e) {
|
||||
// Log as warning since telemetry is non-essential
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
logger.warn(
|
||||
{ error: e, message: errorMessage, lastSent, now },
|
||||
"Failed to send telemetry - applying 1h cooldown"
|
||||
);
|
||||
|
||||
// Failure cooldown: Prevent retrying immediately to avoid hammering the endpoint.
|
||||
// Wait 1 hour before allowing this instance to try again.
|
||||
// Note: Other instances can still try (they'll hit the lock or Redis check).
|
||||
nextTelemetryCheck = now + 60 * 60 * 1000;
|
||||
} finally {
|
||||
// Always release the lock, even if telemetry failed.
|
||||
// This allows other instances to retry if this one failed.
|
||||
await cache.del([TELEMETRY_LOCK_KEY]);
|
||||
}
|
||||
} catch (error) {
|
||||
// Catch-all for any unexpected errors in the wrapper logic (cache failures, lock issues, etc.)
|
||||
// Log as warning since telemetry is non-essential functionality
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(
|
||||
{ error, message: errorMessage, timestamp: Date.now() },
|
||||
"Unexpected error in sendTelemetryEvents wrapper - telemetry check skipped"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gathers telemetry data and sends it to Formbricks Enterprise endpoint.
|
||||
* @param lastSent - Timestamp of last telemetry send (used to calculate incremental metrics)
|
||||
*/
|
||||
const sendTelemetry = async (lastSent: number) => {
|
||||
// Get the oldest organization to generate a stable, anonymized instance ID.
|
||||
// Using the oldest org ensures the ID doesn't change over time.
|
||||
const oldestOrg = await prisma.organization.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true, createdAt: true },
|
||||
});
|
||||
|
||||
if (!oldestOrg) return; // No organization exists, nothing to report
|
||||
const instanceId = createHash("sha256").update(oldestOrg.id).digest("hex");
|
||||
|
||||
// Optimize database queries to reduce connection pool usage:
|
||||
// Instead of 15 parallel queries (which could exhaust the connection pool),
|
||||
// we batch all count queries into a single raw SQL query.
|
||||
// This reduces connection usage from 15 → 3 (batch counts + integrations + accounts).
|
||||
const [countsResult, integrations, ssoProviders] = await Promise.all([
|
||||
// Single query for all counts (13 metrics in one round-trip)
|
||||
prisma.$queryRaw<
|
||||
[
|
||||
{
|
||||
organizationCount: bigint;
|
||||
userCount: bigint;
|
||||
teamCount: bigint;
|
||||
projectCount: bigint;
|
||||
surveyCount: bigint;
|
||||
inProgressSurveyCount: bigint;
|
||||
completedSurveyCount: bigint;
|
||||
responseCountAllTime: bigint;
|
||||
responseCountSinceLastUpdate: bigint;
|
||||
displayCount: bigint;
|
||||
contactCount: bigint;
|
||||
segmentCount: bigint;
|
||||
newestResponseAt: Date | null;
|
||||
},
|
||||
]
|
||||
>`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM "Organization") as "organizationCount",
|
||||
(SELECT COUNT(*) FROM "User") as "userCount",
|
||||
(SELECT COUNT(*) FROM "Team") as "teamCount",
|
||||
(SELECT COUNT(*) FROM "Project") as "projectCount",
|
||||
(SELECT COUNT(*) FROM "Survey") as "surveyCount",
|
||||
(SELECT COUNT(*) FROM "Survey" WHERE status = 'inProgress') as "inProgressSurveyCount",
|
||||
(SELECT COUNT(*) FROM "Survey" WHERE status = 'completed') as "completedSurveyCount",
|
||||
(SELECT COUNT(*) FROM "Response") as "responseCountAllTime",
|
||||
(SELECT COUNT(*) FROM "Response" WHERE "created_at" > ${new Date(lastSent || 0)}) as "responseCountSinceLastUpdate",
|
||||
(SELECT COUNT(*) FROM "Display") as "displayCount",
|
||||
(SELECT COUNT(*) FROM "Contact") as "contactCount",
|
||||
(SELECT COUNT(*) FROM "Segment") as "segmentCount",
|
||||
(SELECT MAX("created_at") FROM "Response") as "newestResponseAt"
|
||||
`,
|
||||
// Keep these as separate queries since they need DISTINCT which is harder to optimize
|
||||
prisma.integration.findMany({ select: { type: true }, distinct: ["type"] }),
|
||||
prisma.account.findMany({ select: { provider: true }, distinct: ["provider"] }),
|
||||
]);
|
||||
|
||||
// Extract metrics from the batched query result and convert bigints to numbers
|
||||
const counts = countsResult[0];
|
||||
const organizationCount = Number(counts.organizationCount);
|
||||
const userCount = Number(counts.userCount);
|
||||
const teamCount = Number(counts.teamCount);
|
||||
const projectCount = Number(counts.projectCount);
|
||||
const surveyCount = Number(counts.surveyCount);
|
||||
const inProgressSurveyCount = Number(counts.inProgressSurveyCount);
|
||||
const completedSurveyCount = Number(counts.completedSurveyCount);
|
||||
const responseCountAllTime = Number(counts.responseCountAllTime);
|
||||
const responseCountSinceLastUpdate = Number(counts.responseCountSinceLastUpdate);
|
||||
const displayCount = Number(counts.displayCount);
|
||||
const contactCount = Number(counts.contactCount);
|
||||
const segmentCount = Number(counts.segmentCount);
|
||||
const newestResponse = counts.newestResponseAt ? { createdAt: counts.newestResponseAt } : null;
|
||||
|
||||
// Convert integration array to boolean map indicating which integrations are configured.
|
||||
const integrationMap = {
|
||||
notion: integrations.some((i) => i.type === IntegrationType.notion),
|
||||
googleSheets: integrations.some((i) => i.type === IntegrationType.googleSheets),
|
||||
airtable: integrations.some((i) => i.type === IntegrationType.airtable),
|
||||
slack: integrations.some((i) => i.type === IntegrationType.slack),
|
||||
};
|
||||
|
||||
// Check SSO configuration: either via environment variables or database records.
|
||||
// This detects which SSO providers are available/configured.
|
||||
const ssoMap = {
|
||||
github: !!env.GITHUB_ID || ssoProviders.some((p) => p.provider === "github"),
|
||||
google: !!env.GOOGLE_CLIENT_ID || ssoProviders.some((p) => p.provider === "google"),
|
||||
azureAd: !!env.AZUREAD_CLIENT_ID || ssoProviders.some((p) => p.provider === "azuread"),
|
||||
oidc: !!env.OIDC_CLIENT_ID || ssoProviders.some((p) => p.provider === "openid"),
|
||||
};
|
||||
|
||||
// Construct telemetry payload with usage statistics and configuration.
|
||||
const payload = {
|
||||
schemaVersion: 1, // Schema version for future compatibility
|
||||
// Core entity counts
|
||||
organizationCount,
|
||||
userCount,
|
||||
teamCount,
|
||||
projectCount,
|
||||
surveyCount,
|
||||
inProgressSurveyCount,
|
||||
completedSurveyCount,
|
||||
// Response metrics
|
||||
responseCountAllTime,
|
||||
responseCountSinceLastUsageUpdate: responseCountSinceLastUpdate, // Incremental since last telemetry
|
||||
displayCount,
|
||||
contactCount,
|
||||
segmentCount,
|
||||
integrations: integrationMap,
|
||||
infrastructure: {
|
||||
smtp: !!env.SMTP_HOST,
|
||||
s3: !!env.S3_BUCKET_NAME,
|
||||
prometheus: !!env.PROMETHEUS_ENABLED,
|
||||
},
|
||||
security: {
|
||||
recaptcha: !!(env.RECAPTCHA_SITE_KEY && env.RECAPTCHA_SECRET_KEY),
|
||||
},
|
||||
sso: ssoMap,
|
||||
meta: {
|
||||
version: packageJson.version, // Formbricks version for compatibility tracking
|
||||
},
|
||||
temporal: {
|
||||
instanceCreatedAt: oldestOrg.createdAt.toISOString(), // When instance was first created
|
||||
newestResponseAt: newestResponse?.createdAt.toISOString() || null, // Most recent activity
|
||||
},
|
||||
};
|
||||
|
||||
// Send telemetry to Formbricks Enterprise endpoint.
|
||||
// This endpoint collects usage statistics for enterprise license validation and analytics.
|
||||
const url = `https://ee.formbricks.com/api/v1/instances/${instanceId}/usage-updates`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry";
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
@@ -227,10 +226,6 @@ export const POST = async (request: Request) => {
|
||||
}
|
||||
});
|
||||
}
|
||||
if (event === "responseCreated") {
|
||||
// Send telemetry events
|
||||
await sendTelemetryEvents();
|
||||
}
|
||||
|
||||
return Response.json({ data: {} });
|
||||
};
|
||||
|
||||
@@ -1109,11 +1109,10 @@ const reviewPrompt = (t: TFunction): TTemplate => {
|
||||
required: false,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonExternal: true,
|
||||
ctaButtonLabel: t("templates.review_prompt_question_2_button_label"),
|
||||
}),
|
||||
],
|
||||
logic: [createBlockJumpLogic(reusableElementIds[1], localSurvey.endings[0].id, "isClicked")],
|
||||
buttonLabel: t("templates.next"),
|
||||
buttonLabel: t("templates.review_prompt_question_2_button_label"),
|
||||
backButtonLabel: t("templates.back"),
|
||||
t,
|
||||
}),
|
||||
@@ -1160,10 +1159,9 @@ const interviewPrompt = (t: TFunction): TTemplate => {
|
||||
buttonUrl: "https://cal.com/johannes",
|
||||
buttonExternal: true,
|
||||
required: false,
|
||||
ctaButtonLabel: t("templates.interview_prompt_question_1_button_label"),
|
||||
}),
|
||||
],
|
||||
buttonLabel: t("templates.next"),
|
||||
buttonLabel: t("templates.interview_prompt_question_1_button_label"),
|
||||
t,
|
||||
}),
|
||||
],
|
||||
@@ -2696,10 +2694,9 @@ const marketSiteClarity = (t: TFunction): TTemplate => {
|
||||
required: false,
|
||||
buttonUrl: "https://app.formbricks.com/auth/signup",
|
||||
buttonExternal: true,
|
||||
ctaButtonLabel: t("templates.market_site_clarity_question_3_button_label"),
|
||||
}),
|
||||
],
|
||||
buttonLabel: t("templates.next"),
|
||||
buttonLabel: t("templates.market_site_clarity_question_3_button_label"),
|
||||
t,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW",
|
||||
"nl-NL",
|
||||
"es-ES",
|
||||
"sv-SE"
|
||||
"es-ES"
|
||||
]
|
||||
},
|
||||
"version": 1.8
|
||||
|
||||
@@ -176,7 +176,6 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
];
|
||||
|
||||
// Billing constants
|
||||
|
||||
@@ -140,7 +140,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "英语(美国)",
|
||||
"nl-NL": "Engels (VS)",
|
||||
"es-ES": "Inglés (EE.UU.)",
|
||||
"sv-SE": "Engelska (USA)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -157,7 +156,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "德语",
|
||||
"nl-NL": "Duits",
|
||||
"es-ES": "Alemán",
|
||||
"sv-SE": "Tyska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -174,7 +172,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "葡萄牙语(巴西)",
|
||||
"nl-NL": "Portugees (Brazilië)",
|
||||
"es-ES": "Portugués (Brasil)",
|
||||
"sv-SE": "Portugisiska (Brasilien)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -191,7 +188,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "法语",
|
||||
"nl-NL": "Frans",
|
||||
"es-ES": "Francés",
|
||||
"sv-SE": "Franska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -203,12 +199,11 @@ export const appLanguages = [
|
||||
"fr-FR": "Chinois (Traditionnel)",
|
||||
"zh-Hant-TW": "繁體中文",
|
||||
"pt-PT": "Chinês (Tradicional)",
|
||||
"ro-RO": "Chineza (Tradițională)",
|
||||
"ro-RO": "Chineză (Tradicională)",
|
||||
"ja-JP": "中国語(繁体字)",
|
||||
"zh-Hans-CN": "繁体中文",
|
||||
"nl-NL": "Chinees (Traditioneel)",
|
||||
"es-ES": "Chino (Tradicional)",
|
||||
"sv-SE": "Kinesiska (traditionell)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -225,7 +220,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
|
||||
"nl-NL": "Portugees (Portugal)",
|
||||
"es-ES": "Portugués (Portugal)",
|
||||
"sv-SE": "Portugisiska (Portugal)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -242,7 +236,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "罗马尼亚语",
|
||||
"nl-NL": "Roemeens",
|
||||
"es-ES": "Rumano",
|
||||
"sv-SE": "Rumänska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -259,7 +252,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "日语",
|
||||
"nl-NL": "Japans",
|
||||
"es-ES": "Japonés",
|
||||
"sv-SE": "Japanska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -271,12 +263,11 @@ export const appLanguages = [
|
||||
"fr-FR": "Chinois (Simplifié)",
|
||||
"zh-Hant-TW": "簡體中文",
|
||||
"pt-PT": "Chinês (Simplificado)",
|
||||
"ro-RO": "Chineza (Simplificată)",
|
||||
"ro-RO": "Chineză (Simplificată)",
|
||||
"ja-JP": "中国語(簡体字)",
|
||||
"zh-Hans-CN": "简体中文",
|
||||
"nl-NL": "Chinees (Vereenvoudigd)",
|
||||
"es-ES": "Chino (Simplificado)",
|
||||
"sv-SE": "Kinesiska (förenklad)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -288,12 +279,11 @@ export const appLanguages = [
|
||||
"fr-FR": "Néerlandais",
|
||||
"zh-Hant-TW": "荷蘭語",
|
||||
"pt-PT": "Holandês",
|
||||
"ro-RO": "Olandeza",
|
||||
"ro-RO": "Olandeză",
|
||||
"ja-JP": "オランダ語",
|
||||
"zh-Hans-CN": "荷兰语",
|
||||
"nl-NL": "Nederlands",
|
||||
"es-ES": "Neerlandés",
|
||||
"sv-SE": "Nederländska",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -310,24 +300,6 @@ export const appLanguages = [
|
||||
"zh-Hans-CN": "西班牙语",
|
||||
"nl-NL": "Spaans",
|
||||
"es-ES": "Español",
|
||||
"sv-SE": "Spanska",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "sv-SE",
|
||||
label: {
|
||||
"en-US": "Swedish",
|
||||
"de-DE": "Schwedisch",
|
||||
"pt-BR": "Sueco",
|
||||
"fr-FR": "Suédois",
|
||||
"zh-Hant-TW": "瑞典語",
|
||||
"pt-PT": "Sueco",
|
||||
"ro-RO": "Suedeză",
|
||||
"ja-JP": "スウェーデン語",
|
||||
"zh-Hans-CN": "瑞典语",
|
||||
"nl-NL": "Zweeds",
|
||||
"es-ES": "Sueco",
|
||||
"sv-SE": "Svenska",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -69,12 +69,6 @@ describe("Time Utilities", () => {
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSince(oneHourAgo.toISOString(), "de-DE")).toBe("vor etwa 1 Stunde");
|
||||
});
|
||||
|
||||
test("should format time since in Swedish", () => {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeSinceDate", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDistance, intlFormat } from "date-fns";
|
||||
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, sv, zhCN, zhTW } from "date-fns/locale";
|
||||
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
@@ -93,8 +93,6 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
return fr;
|
||||
case "nl-NL":
|
||||
return nl;
|
||||
case "sv-SE":
|
||||
return sv;
|
||||
case "zh-Hant-TW":
|
||||
return zhTW;
|
||||
case "pt-PT":
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as nextHeaders from "next/headers";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants";
|
||||
import { appLanguages } from "@/lib/i18n/utils";
|
||||
import { findMatchingLocale } from "./locale";
|
||||
|
||||
// Mock the Next.js headers function
|
||||
@@ -85,25 +84,4 @@ describe("locale", () => {
|
||||
expect(result).toBe(germanLocale);
|
||||
expect(nextHeaders.headers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Swedish locale (sv-SE) is available and selectable", async () => {
|
||||
// Verify sv-SE is in AVAILABLE_LOCALES
|
||||
expect(AVAILABLE_LOCALES).toContain("sv-SE");
|
||||
|
||||
// Verify Swedish has a language entry with proper labels
|
||||
const swedishLanguage = appLanguages.find((lang) => lang.code === "sv-SE");
|
||||
expect(swedishLanguage).toBeDefined();
|
||||
expect(swedishLanguage?.label["en-US"]).toBe("Swedish");
|
||||
expect(swedishLanguage?.label["sv-SE"]).toBe("Svenska");
|
||||
|
||||
// Verify the locale can be matched from Accept-Language header
|
||||
vi.mocked(nextHeaders.headers).mockReturnValue({
|
||||
get: vi.fn().mockReturnValue("sv-SE,en-US"),
|
||||
} as any);
|
||||
|
||||
const result = await findMatchingLocale();
|
||||
|
||||
expect(result).toBe("sv-SE");
|
||||
expect(nextHeaders.headers).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -220,7 +220,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
],
|
||||
DEFAULT_LOCALE: "en-US",
|
||||
BREVO_API_KEY: "mock-brevo-api-key",
|
||||
|
||||
60
packages/cache/src/service.ts
vendored
60
packages/cache/src/service.ts
vendored
@@ -3,7 +3,7 @@ import type { RedisClient } from "@/types/client";
|
||||
import { type CacheError, CacheErrorClass, ErrorCode, type Result, err, ok } from "@/types/error";
|
||||
import type { CacheKey } from "@/types/keys";
|
||||
import { ZCacheKey } from "@/types/keys";
|
||||
import { ZTtlMs, ZTtlMsOptional } from "@/types/service";
|
||||
import { ZTtlMs } from "@/types/service";
|
||||
import { validateInputs } from "./utils/validation";
|
||||
|
||||
/**
|
||||
@@ -116,13 +116,13 @@ export class CacheService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in cache with automatic JSON serialization and optional TTL
|
||||
* Set a value in cache with automatic JSON serialization and TTL
|
||||
* @param key - Cache key to store under
|
||||
* @param value - Value to store
|
||||
* @param ttlMs - Time to live in milliseconds (optional - if omitted, key persists indefinitely)
|
||||
* @param ttlMs - Time to live in milliseconds
|
||||
* @returns Result containing void or an error
|
||||
*/
|
||||
async set(key: CacheKey, value: unknown, ttlMs?: number): Promise<Result<void, CacheError>> {
|
||||
async set(key: CacheKey, value: unknown, ttlMs: number): Promise<Result<void, CacheError>> {
|
||||
// Check Redis availability first
|
||||
if (!this.isRedisClientReady()) {
|
||||
return err({
|
||||
@@ -130,8 +130,8 @@ export class CacheService {
|
||||
});
|
||||
}
|
||||
|
||||
// Validate key and optional TTL
|
||||
const validation = validateInputs([key, ZCacheKey], [ttlMs, ZTtlMsOptional]);
|
||||
// Validate both key and TTL in one call
|
||||
const validation = validateInputs([key, ZCacheKey], [ttlMs, ZTtlMs]);
|
||||
if (!validation.ok) {
|
||||
return validation;
|
||||
}
|
||||
@@ -141,13 +141,7 @@ export class CacheService {
|
||||
const normalizedValue = value === undefined ? null : value;
|
||||
const serialized = JSON.stringify(normalizedValue);
|
||||
|
||||
if (ttlMs === undefined) {
|
||||
// Set without expiration (persists indefinitely)
|
||||
await this.withTimeout(this.redis.set(key, serialized));
|
||||
} else {
|
||||
// Set with expiration
|
||||
await this.withTimeout(this.redis.setEx(key, Math.floor(ttlMs / 1000), serialized));
|
||||
}
|
||||
await this.withTimeout(this.redis.setEx(key, Math.floor(ttlMs / 1000), serialized));
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
logger.error({ error, key, ttlMs }, "Cache set operation failed");
|
||||
@@ -191,44 +185,6 @@ export class CacheService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to acquire a distributed lock (atomic SET NX operation)
|
||||
* @param key - Lock key
|
||||
* @param value - Lock value (typically "locked" or instance identifier)
|
||||
* @param ttlMs - Time to live in milliseconds (lock expiration)
|
||||
* @returns Result containing boolean indicating if lock was acquired, or an error
|
||||
*/
|
||||
async tryLock(key: CacheKey, value: string, ttlMs: number): Promise<Result<boolean, CacheError>> {
|
||||
// Check Redis availability first
|
||||
if (!this.isRedisClientReady()) {
|
||||
return err({
|
||||
code: ErrorCode.RedisConnectionError,
|
||||
});
|
||||
}
|
||||
|
||||
const validation = validateInputs([key, ZCacheKey], [ttlMs, ZTtlMs]);
|
||||
if (!validation.ok) {
|
||||
return validation;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use SET with NX (only set if not exists) and PX (expiration in milliseconds) for atomic lock acquisition
|
||||
const result = await this.withTimeout(
|
||||
this.redis.set(key, value, {
|
||||
NX: true,
|
||||
PX: ttlMs,
|
||||
})
|
||||
);
|
||||
// SET returns "OK" if lock was acquired, null if key already exists
|
||||
return ok(result === "OK");
|
||||
} catch (error) {
|
||||
logger.error({ error, key, ttlMs }, "Cache lock operation failed");
|
||||
return err({
|
||||
code: ErrorCode.RedisOperationError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache wrapper for functions (cache-aside).
|
||||
* Never throws due to cache errors; function errors propagate without retry.
|
||||
@@ -287,7 +243,7 @@ export class CacheService {
|
||||
}
|
||||
|
||||
private async trySetCache(key: CacheKey, value: unknown, ttlMs: number): Promise<void> {
|
||||
if (value === undefined) {
|
||||
if (typeof value === "undefined") {
|
||||
return; // Skip caching undefined values
|
||||
}
|
||||
|
||||
|
||||
7
packages/cache/types/service.ts
vendored
7
packages/cache/types/service.ts
vendored
@@ -5,10 +5,3 @@ export const ZTtlMs = z
|
||||
.int()
|
||||
.min(1000, "TTL must be at least 1000ms (1 second)")
|
||||
.finite("TTL must be finite");
|
||||
|
||||
export const ZTtlMsOptional = z
|
||||
.number()
|
||||
.int()
|
||||
.min(1000, "TTL must be at least 1000ms (1 second)")
|
||||
.finite("TTL must be finite")
|
||||
.optional();
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { MigrationScript } from "../../src/scripts/migration-runner";
|
||||
import type {
|
||||
Block,
|
||||
CTAMigrationStats,
|
||||
IntegrationConfig,
|
||||
IntegrationMigrationStats,
|
||||
MigratedIntegration,
|
||||
SurveyRecord,
|
||||
} from "./types";
|
||||
import { migrateIntegrationConfig, migrateQuestionsSurveyToBlocks } from "./utils";
|
||||
import type { Block, CTAMigrationStats, SurveyRecord } from "./types";
|
||||
import { migrateQuestionsSurveyToBlocks } from "./utils";
|
||||
|
||||
export const migrateQuestionsToBlocks: MigrationScript = {
|
||||
type: "data",
|
||||
@@ -32,198 +25,71 @@ export const migrateQuestionsToBlocks: MigrationScript = {
|
||||
|
||||
if (surveys.length === 0) {
|
||||
logger.info("No surveys found that need migration");
|
||||
} else {
|
||||
logger.info(`Found ${surveys.length.toString()} surveys to migrate`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Process each survey
|
||||
const updates: { id: string; blocks: Block[] }[] = [];
|
||||
logger.info(`Found ${surveys.length.toString()} surveys to migrate`);
|
||||
|
||||
for (const survey of surveys) {
|
||||
try {
|
||||
const migrated = migrateQuestionsSurveyToBlocks(survey, createId, ctaStats);
|
||||
updates.push({
|
||||
id: migrated.id,
|
||||
blocks: migrated.blocks,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to migrate survey ${survey.id}`);
|
||||
throw new Error(
|
||||
`Migration failed for survey ${survey.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
// 2. Process each survey
|
||||
const updates: { id: string; blocks: Block[] }[] = [];
|
||||
|
||||
logger.info(`Successfully processed ${updates.length.toString()} surveys`);
|
||||
|
||||
// 3. Update surveys in batches using UNNEST for performance
|
||||
// Batch size of 150 balances performance with query size safety (~7.5MB per batch)
|
||||
const SURVEY_BATCH_SIZE = 150;
|
||||
let updatedCount = 0;
|
||||
|
||||
for (let i = 0; i < updates.length; i += SURVEY_BATCH_SIZE) {
|
||||
const batch = updates.slice(i, i + SURVEY_BATCH_SIZE);
|
||||
|
||||
try {
|
||||
// Build arrays for batch update
|
||||
const ids = batch.map((u) => u.id);
|
||||
const blocksJsonStrings = batch.map((u) => JSON.stringify(u.blocks));
|
||||
|
||||
// Use UNNEST to update multiple surveys in a single query
|
||||
await tx.$executeRawUnsafe(
|
||||
`UPDATE "Survey" AS s
|
||||
SET
|
||||
blocks = (
|
||||
SELECT array_agg(elem)
|
||||
FROM jsonb_array_elements(data.blocks_json::jsonb) AS elem
|
||||
),
|
||||
questions = '[]'::jsonb
|
||||
FROM (
|
||||
SELECT
|
||||
unnest($1::text[]) AS id,
|
||||
unnest($2::text[]) AS blocks_json
|
||||
) AS data
|
||||
WHERE s.id = data.id`,
|
||||
ids,
|
||||
blocksJsonStrings
|
||||
);
|
||||
|
||||
updatedCount += batch.length;
|
||||
|
||||
// Log progress
|
||||
logger.info(`Progress: ${updatedCount.toString()}/${updates.length.toString()} surveys updated`);
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to update survey batch starting at index ${i.toString()}`);
|
||||
throw new Error(
|
||||
`Database batch update failed at index ${i.toString()}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Migration complete: ${updatedCount.toString()} surveys migrated to blocks`);
|
||||
|
||||
// 4. Log CTA migration statistics
|
||||
if (ctaStats.totalCTAElements > 0) {
|
||||
logger.info(
|
||||
`CTA elements processed: ${ctaStats.totalCTAElements.toString()} total (${ctaStats.ctaWithExternalLink.toString()} with external link, ${ctaStats.ctaWithoutExternalLink.toString()} without)`
|
||||
for (const survey of surveys) {
|
||||
try {
|
||||
const migrated = migrateQuestionsSurveyToBlocks(survey, createId, ctaStats);
|
||||
updates.push({
|
||||
id: migrated.id,
|
||||
blocks: migrated.blocks,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to migrate survey ${survey.id}`);
|
||||
throw new Error(
|
||||
`Migration failed for survey ${survey.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Migrate Integration configs
|
||||
logger.info("Starting integration config migration");
|
||||
logger.info(`Successfully processed ${updates.length.toString()} surveys`);
|
||||
|
||||
// Initialize integration statistics
|
||||
const integrationStats: IntegrationMigrationStats = {
|
||||
totalIntegrations: 0,
|
||||
googleSheets: { processed: 0, skipped: 0 },
|
||||
airtable: { processed: 0, skipped: 0 },
|
||||
slack: { processed: 0, skipped: 0 },
|
||||
notion: { processed: 0, skipped: 0 },
|
||||
n8n: { skipped: 0 },
|
||||
errors: 0,
|
||||
};
|
||||
// 3. Update surveys individually for safety (avoids SQL injection risks with complex JSONB arrays)
|
||||
let updatedCount = 0;
|
||||
|
||||
// Query all integrations
|
||||
const integrations = await tx.$queryRaw<{ id: string; type: string; config: IntegrationConfig }[]>`
|
||||
SELECT id, type, config
|
||||
FROM "Integration"
|
||||
`;
|
||||
|
||||
integrationStats.totalIntegrations = integrations.length;
|
||||
|
||||
if (integrations.length === 0) {
|
||||
logger.info("No integrations found to migrate");
|
||||
} else {
|
||||
logger.info(`Found ${integrations.length.toString()} integrations to process`);
|
||||
|
||||
// Process integrations in memory
|
||||
const integrationUpdates: MigratedIntegration[] = [];
|
||||
|
||||
for (const integration of integrations) {
|
||||
try {
|
||||
// Config is JSON from database - cast to IntegrationConfig for runtime processing
|
||||
const result = migrateIntegrationConfig(integration.type, integration.config);
|
||||
|
||||
// Track statistics
|
||||
const typeStats = integrationStats[integration.type as keyof typeof integrationStats];
|
||||
if (typeStats && typeof typeStats === "object" && "processed" in typeStats) {
|
||||
if (result.migrated) {
|
||||
typeStats.processed++;
|
||||
integrationUpdates.push({
|
||||
id: integration.id,
|
||||
config: result.config,
|
||||
});
|
||||
} else {
|
||||
typeStats.skipped++;
|
||||
}
|
||||
} else if (integration.type === "n8n") {
|
||||
integrationStats.n8n.skipped++;
|
||||
}
|
||||
} catch (error) {
|
||||
integrationStats.errors++;
|
||||
logger.error(error, `Failed to migrate integration ${integration.id} (type: ${integration.type})`);
|
||||
throw new Error(
|
||||
`Migration failed for integration ${integration.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Processed ${integrations.length.toString()} integrations: ${integrationUpdates.length.toString()} to update, ${(integrations.length - integrationUpdates.length).toString()} skipped`
|
||||
);
|
||||
|
||||
// Update integrations using Promise.all for better throughput
|
||||
if (integrationUpdates.length > 0) {
|
||||
// Batch size of 150 provides good parallelization (~750KB per batch)
|
||||
const INTEGRATION_BATCH_SIZE = 150;
|
||||
let integrationUpdatedCount = 0;
|
||||
|
||||
for (let i = 0; i < integrationUpdates.length; i += INTEGRATION_BATCH_SIZE) {
|
||||
const batch = integrationUpdates.slice(i, i + INTEGRATION_BATCH_SIZE);
|
||||
|
||||
try {
|
||||
// Execute all updates in parallel for this batch
|
||||
await Promise.all(
|
||||
batch.map((update) =>
|
||||
tx.$executeRawUnsafe(
|
||||
`UPDATE "Integration"
|
||||
SET config = $1::jsonb
|
||||
WHERE id = $2`,
|
||||
JSON.stringify(update.config),
|
||||
update.id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
integrationUpdatedCount += batch.length;
|
||||
|
||||
// Log progress
|
||||
logger.info(
|
||||
`Integration progress: ${integrationUpdatedCount.toString()}/${integrationUpdates.length.toString()} updated`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to update integration batch starting at index ${i.toString()}`);
|
||||
throw new Error(
|
||||
`Database update failed for integration batch at index ${i.toString()}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Integration migration complete: ${integrationUpdatedCount.toString()} integrations updated`
|
||||
for (const update of updates) {
|
||||
try {
|
||||
// PostgreSQL requires proper array format for jsonb[]
|
||||
// We need to convert the JSON array to a PostgreSQL jsonb array using array_to_json
|
||||
// The trick is to use jsonb_array_elements to convert the JSON array into rows, then array_agg to collect them back
|
||||
await tx.$executeRawUnsafe(
|
||||
`UPDATE "Survey"
|
||||
SET blocks = (
|
||||
SELECT array_agg(elem)
|
||||
FROM jsonb_array_elements($1::jsonb) AS elem
|
||||
),
|
||||
questions = '[]'::jsonb
|
||||
WHERE id = $2`,
|
||||
JSON.stringify(update.blocks),
|
||||
update.id
|
||||
);
|
||||
} else {
|
||||
logger.info("No integrations needed updating (all already migrated or skipped)");
|
||||
}
|
||||
|
||||
// Log detailed statistics
|
||||
updatedCount++;
|
||||
|
||||
// Log progress every 10000 surveys
|
||||
if (updatedCount % 10000 === 0) {
|
||||
logger.info(`Progress: ${updatedCount.toString()}/${updates.length.toString()} surveys updated`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to update survey ${update.id} in database`);
|
||||
throw new Error(
|
||||
`Database update failed for survey ${update.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Migration complete: ${updatedCount.toString()} surveys migrated to blocks`);
|
||||
|
||||
// 4. Log CTA migration statistics
|
||||
if (ctaStats.totalCTAElements > 0) {
|
||||
logger.info(
|
||||
`Integration statistics: ` +
|
||||
`GoogleSheets: ${integrationStats.googleSheets.processed.toString()} migrated, ${integrationStats.googleSheets.skipped.toString()} skipped | ` +
|
||||
`Airtable: ${integrationStats.airtable.processed.toString()} migrated, ${integrationStats.airtable.skipped.toString()} skipped | ` +
|
||||
`Slack: ${integrationStats.slack.processed.toString()} migrated, ${integrationStats.slack.skipped.toString()} skipped | ` +
|
||||
`Notion: ${integrationStats.notion.processed.toString()} migrated, ${integrationStats.notion.skipped.toString()} skipped | ` +
|
||||
`n8n: ${integrationStats.n8n.skipped.toString()} skipped`
|
||||
`CTA elements processed: ${ctaStats.totalCTAElements.toString()} total (${ctaStats.ctaWithExternalLink.toString()} with external link, ${ctaStats.ctaWithoutExternalLink.toString()} without)`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,151 +76,6 @@ export interface CTAMigrationStats {
|
||||
ctaWithoutExternalLink: number;
|
||||
}
|
||||
|
||||
// Base integration config data (shared between all integrations except Notion)
|
||||
// This represents both old (questionIds/questions) and new (elementIds/elements) formats
|
||||
export interface IntegrationBaseSurveyData {
|
||||
createdAt: Date;
|
||||
surveyId: string;
|
||||
surveyName: string;
|
||||
// Old format fields
|
||||
questionIds?: string[];
|
||||
questions?: string;
|
||||
// New format fields
|
||||
elementIds?: string[];
|
||||
elements?: string;
|
||||
// Optional fields
|
||||
includeVariables?: boolean;
|
||||
includeHiddenFields?: boolean;
|
||||
includeMetadata?: boolean;
|
||||
includeCreatedAt?: boolean;
|
||||
}
|
||||
|
||||
// Google Sheets specific config
|
||||
export interface GoogleSheetsConfigData extends IntegrationBaseSurveyData {
|
||||
spreadsheetId: string;
|
||||
spreadsheetName: string;
|
||||
}
|
||||
|
||||
export interface GoogleSheetsConfig {
|
||||
key: {
|
||||
token_type: "Bearer";
|
||||
access_token: string;
|
||||
scope: string;
|
||||
expiry_date: number;
|
||||
refresh_token: string;
|
||||
};
|
||||
data: GoogleSheetsConfigData[];
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Airtable specific config
|
||||
export interface AirtableConfigData extends IntegrationBaseSurveyData {
|
||||
tableId: string;
|
||||
baseId: string;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
export interface AirtableConfig {
|
||||
key: {
|
||||
expiry_date: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
data: AirtableConfigData[];
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Slack specific config
|
||||
export interface SlackConfigData extends IntegrationBaseSurveyData {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
export interface SlackConfig {
|
||||
key: {
|
||||
app_id: string;
|
||||
authed_user: { id: string };
|
||||
token_type: "bot";
|
||||
access_token: string;
|
||||
bot_user_id: string;
|
||||
team: { id: string; name: string };
|
||||
};
|
||||
data: SlackConfigData[];
|
||||
}
|
||||
|
||||
// Notion specific config (different structure - uses mapping instead of elementIds/elements)
|
||||
export interface NotionMappingItem {
|
||||
// Old format
|
||||
question?: { id: string; name: string; type: string };
|
||||
// New format
|
||||
element?: { id: string; name: string; type: string };
|
||||
column: { id: string; name: string; type: string };
|
||||
}
|
||||
|
||||
export interface NotionConfigData {
|
||||
createdAt: Date;
|
||||
surveyId: string;
|
||||
surveyName: string;
|
||||
mapping: NotionMappingItem[];
|
||||
databaseId: string;
|
||||
databaseName: string;
|
||||
}
|
||||
|
||||
export interface NotionConfig {
|
||||
key: {
|
||||
access_token: string;
|
||||
bot_id: string;
|
||||
token_type: string;
|
||||
duplicated_template_id: string | null;
|
||||
owner: {
|
||||
type: string;
|
||||
workspace?: boolean | null;
|
||||
user: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
type?: string | null;
|
||||
object: string;
|
||||
person?: { email: string } | null;
|
||||
avatar_url?: string | null;
|
||||
} | null;
|
||||
};
|
||||
workspace_icon: string | null;
|
||||
workspace_id: string;
|
||||
workspace_name: string | null;
|
||||
};
|
||||
data: NotionConfigData[];
|
||||
}
|
||||
|
||||
// Union type for all integration configs
|
||||
export type IntegrationConfig =
|
||||
| GoogleSheetsConfig
|
||||
| AirtableConfig
|
||||
| SlackConfig
|
||||
| NotionConfig
|
||||
| Record<string, unknown>;
|
||||
|
||||
// Integration migration types
|
||||
export interface IntegrationRecord {
|
||||
id: string;
|
||||
type: string;
|
||||
config: IntegrationConfig;
|
||||
}
|
||||
|
||||
export interface MigratedIntegration {
|
||||
id: string;
|
||||
config: IntegrationConfig;
|
||||
}
|
||||
|
||||
export interface IntegrationMigrationStats {
|
||||
totalIntegrations: number;
|
||||
googleSheets: { processed: number; skipped: number };
|
||||
airtable: { processed: number; skipped: number };
|
||||
slack: { processed: number; skipped: number };
|
||||
notion: { processed: number; skipped: number };
|
||||
n8n: { skipped: number };
|
||||
errors: number;
|
||||
}
|
||||
|
||||
// Type guards
|
||||
export const isSingleCondition = (condition: Condition): condition is SingleCondition => {
|
||||
return "leftOperand" in condition && "operator" in condition;
|
||||
|
||||
@@ -3,10 +3,8 @@ import {
|
||||
type CTAMigrationStats,
|
||||
type Condition,
|
||||
type ConditionGroup,
|
||||
type IntegrationConfig,
|
||||
type LogicAction,
|
||||
type MigratedSurvey,
|
||||
type NotionConfig,
|
||||
type SingleCondition,
|
||||
type SurveyLogic,
|
||||
type SurveyQuestion,
|
||||
@@ -416,198 +414,3 @@ export const migrateQuestionsSurveyToBlocks = (
|
||||
blocks,
|
||||
};
|
||||
};
|
||||
|
||||
// Type guard for config items with data array
|
||||
interface ConfigWithData {
|
||||
data: Record<string, unknown>[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const hasDataArray = (config: unknown): config is ConfigWithData => {
|
||||
return (
|
||||
typeof config === "object" &&
|
||||
config !== null &&
|
||||
"data" in config &&
|
||||
Array.isArray((config as ConfigWithData).data)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if config item is already migrated (has elementIds/elements)
|
||||
*/
|
||||
const isAlreadyMigrated = (item: Record<string, unknown>): boolean => {
|
||||
return "elementIds" in item || "elements" in item;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if config item needs migration (has questionIds/questions)
|
||||
*/
|
||||
const needsMigration = (item: Record<string, unknown>): boolean => {
|
||||
return "questionIds" in item || "questions" in item;
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate Airtable/Google Sheets/Slack config (shared base type)
|
||||
* Returns an object with migrated flag and updated config
|
||||
*/
|
||||
export const migrateSharedIntegrationConfig = (
|
||||
config: IntegrationConfig
|
||||
): { migrated: boolean; config: IntegrationConfig } => {
|
||||
// Validate config structure
|
||||
if (!hasDataArray(config)) {
|
||||
return { migrated: false, config };
|
||||
}
|
||||
|
||||
let anyMigrated = false;
|
||||
|
||||
const newData = config.data.map((item) => {
|
||||
// Skip if already migrated
|
||||
if (isAlreadyMigrated(item)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
// Skip if nothing to migrate
|
||||
if (!needsMigration(item)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
anyMigrated = true;
|
||||
const migrated: Record<string, unknown> = { ...item };
|
||||
|
||||
// Rename questionIds to elementIds
|
||||
if ("questionIds" in migrated) {
|
||||
migrated.elementIds = migrated.questionIds;
|
||||
delete migrated.questionIds;
|
||||
}
|
||||
|
||||
// Rename questions to elements
|
||||
if ("questions" in migrated) {
|
||||
migrated.elements = migrated.questions;
|
||||
delete migrated.questions;
|
||||
}
|
||||
|
||||
// All other fields (includeVariables, etc.) are preserved automatically via spread
|
||||
|
||||
return migrated;
|
||||
});
|
||||
|
||||
return {
|
||||
migrated: anyMigrated,
|
||||
config: { ...config, data: newData },
|
||||
};
|
||||
};
|
||||
|
||||
// Type guard for Notion config
|
||||
const isNotionConfig = (config: unknown): config is NotionConfig => {
|
||||
return (
|
||||
typeof config === "object" &&
|
||||
config !== null &&
|
||||
"data" in config &&
|
||||
Array.isArray((config as NotionConfig).data)
|
||||
);
|
||||
};
|
||||
|
||||
// Type for Notion mapping entry
|
||||
interface NotionMappingEntry {
|
||||
question?: { id: string; name: string; type: string };
|
||||
element?: { id: string; name: string; type: string };
|
||||
column: { id: string; name: string; type: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Notion config item has any mapping entries that need migration
|
||||
* @param mapping - Notion mapping entries
|
||||
* @returns boolean
|
||||
*/
|
||||
const needsNotionMigration = (mapping: NotionMappingEntry[] | undefined): boolean => {
|
||||
if (!mapping || !Array.isArray(mapping) || mapping.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if ANY mapping item has "question" field (needs migration)
|
||||
return mapping.some((mapItem) => "question" in mapItem && !("element" in mapItem));
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate Notion config (custom mapping structure)
|
||||
* @param config - Notion config
|
||||
* @returns \{ migrated: boolean; config: IntegrationConfig \}
|
||||
*/
|
||||
export const migrateNotionIntegrationConfig = (
|
||||
config: IntegrationConfig
|
||||
): { migrated: boolean; config: IntegrationConfig } => {
|
||||
// Validate config structure
|
||||
if (!isNotionConfig(config)) {
|
||||
return { migrated: false, config };
|
||||
}
|
||||
|
||||
let anyMigrated = false;
|
||||
|
||||
const newData = config.data.map((item) => {
|
||||
// Cast mapping to the migration type that includes both old and new formats
|
||||
const mapping = item.mapping as NotionMappingEntry[] | undefined;
|
||||
|
||||
// Skip if nothing to migrate
|
||||
if (!needsNotionMigration(mapping)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
anyMigrated = true;
|
||||
|
||||
// Migrate mapping array - check EACH item individually
|
||||
const newMapping = mapping?.map((mapItem) => {
|
||||
// Already has element field - skip this item
|
||||
if ("element" in mapItem) {
|
||||
return mapItem;
|
||||
}
|
||||
|
||||
// Has question field - migrate it
|
||||
if ("question" in mapItem) {
|
||||
const { question, ...rest } = mapItem;
|
||||
return {
|
||||
...rest,
|
||||
element: question,
|
||||
};
|
||||
}
|
||||
|
||||
// Neither element nor question - return as is
|
||||
return mapItem;
|
||||
});
|
||||
|
||||
return {
|
||||
...item,
|
||||
mapping: newMapping,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
migrated: anyMigrated,
|
||||
config: { ...config, data: newData },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate integration config based on type
|
||||
* @param type - Integration type
|
||||
* @param config - Integration config
|
||||
* @returns \{ migrated: boolean; config: IntegrationConfig \}
|
||||
*/
|
||||
export const migrateIntegrationConfig = (
|
||||
type: string,
|
||||
config: IntegrationConfig
|
||||
): { migrated: boolean; config: IntegrationConfig } => {
|
||||
switch (type) {
|
||||
case "googleSheets":
|
||||
case "airtable":
|
||||
case "slack":
|
||||
return migrateSharedIntegrationConfig(config);
|
||||
case "notion":
|
||||
return migrateNotionIntegrationConfig(config);
|
||||
case "n8n":
|
||||
// n8n has no config schema to migrate
|
||||
return { migrated: false, config };
|
||||
default:
|
||||
// Unknown type - return unchanged
|
||||
return { migrated: false, config };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["de", "it", "fr", "es", "ar", "pt", "ru", "uz", "ro", "ja", "zh-Hans", "hi", "nl", "sv"]
|
||||
"targets": ["de", "it", "fr", "es", "ar", "pt", "ru", "uz", "ro", "ja", "zh-Hans", "hi", "nl"]
|
||||
},
|
||||
"version": 1.8
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"and": "och",
|
||||
"apply": "Tillämpa",
|
||||
"auto_close_wrapper": "Automatisk stängning",
|
||||
"back": "Tillbaka",
|
||||
"click_or_drag_to_upload_files": "Klicka eller dra för att ladda upp filer.",
|
||||
"close_survey": "Stäng enkät",
|
||||
"company_logo": "Företagslogotyp",
|
||||
"delete_file": "Ta bort fil",
|
||||
"file_upload": "Ladda upp fil",
|
||||
"finish": "Slutför",
|
||||
"language_switch": "Språkväxlare",
|
||||
"less_than_x_minutes": "{count, plural, one {mindre än 1 minut} other {mindre än {count} minuter}}",
|
||||
"move_down": "Flytta {item} nedåt",
|
||||
"move_up": "Flytta {item} uppåt",
|
||||
"next": "Nästa",
|
||||
"open_in_new_tab": "Öppna i ny flik",
|
||||
"optional": "Valfritt",
|
||||
"options": "Alternativ",
|
||||
"people_responded": "{count, plural, one {1 person har svarat} other {{count} personer har svarat}}",
|
||||
"please_retry_now_or_try_again_later": "Försök igen nu eller försök igen senare.",
|
||||
"powered_by": "Drivs av",
|
||||
"privacy_policy": "Integritetspolicy",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Skyddas av reCAPTCHA och Googles",
|
||||
"question": "Fråga",
|
||||
"question_video": "Frågevideo",
|
||||
"ranking_items": "Rangordna objekt",
|
||||
"respondents_will_not_see_this_card": "Respondenter kommer inte att se detta kort",
|
||||
"retry": "Försök igen",
|
||||
"select_a_date": "Välj ett datum",
|
||||
"select_for_ranking": "Välj {item} för rangordning",
|
||||
"sending_responses": "Skickar svar...",
|
||||
"takes": "Tar",
|
||||
"terms_of_service": "Användarvillkor",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Servrarna kan inte nås för tillfället.",
|
||||
"they_will_be_redirected_immediately": "De kommer att omdirigeras omedelbart",
|
||||
"upload_files_by_clicking_or_dragging_them_here": "Ladda upp filer genom att klicka eller dra dem hit",
|
||||
"uploading": "Laddar upp",
|
||||
"x_minutes": "{count, plural, one {1 minut} other {{count} minuter}}",
|
||||
"x_plus_minutes": "{count}+ minuter",
|
||||
"you_have_selected_x_date": "Du har valt {date}",
|
||||
"you_have_successfully_uploaded_the_file": "Du har framgångsrikt laddat upp filen {fileName}",
|
||||
"your_feedback_is_stuck": "Din feedback fastnade :("
|
||||
},
|
||||
"errors": {
|
||||
"file_input": {
|
||||
"duplicate_files": "Följande filer är redan uppladdade: {duplicateNames}. Dubbletter av filer är inte tillåtna.",
|
||||
"file_size_exceeded": "Följande filer överstiger maxstorleken på {maxSizeInMB} MB och togs bort: {fileNames}",
|
||||
"file_size_exceeded_alert": "Filen måste vara mindre än {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "Inga giltiga filtyper valda. Vänligen välj en giltig filtyp.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Endast en fil kan laddas upp åt gången.",
|
||||
"upload_failed": "Uppladdning misslyckades! Försök igen.",
|
||||
"you_can_only_upload_a_maximum_of_files": "Du kan ladda upp maximalt {FILE_LIMIT} filer."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
"message": "Vänligen inaktivera skräppostskyddet i enkätinställningarna för att fortsätta använda denna enhet.",
|
||||
"title": "Denna enhet stöder inte skräppostskydd."
|
||||
},
|
||||
"please_book_an_appointment": "Vänligen boka ett möte",
|
||||
"please_enter_a_valid_email_address": "Vänligen ange en giltig e-postadress",
|
||||
"please_enter_a_valid_phone_number": "Vänligen ange ett giltigt telefonnummer",
|
||||
"please_enter_a_valid_url": "Vänligen ange en giltig URL",
|
||||
"please_fill_out_this_field": "Vänligen fyll i detta fält",
|
||||
"please_rank_all_items_before_submitting": "Vänligen rangordna alla objekt innan du skickar",
|
||||
"please_select_a_date": "Vänligen välj ett datum",
|
||||
"please_upload_a_file": "Vänligen ladda upp en fil",
|
||||
"recaptcha_error": {
|
||||
"message": "Ditt svar kunde inte skickas eftersom det flaggades som automatiserad aktivitet. Om du andas, försök igen.",
|
||||
"title": "Vi kunde inte verifiera att du är människa."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import nlTranslations from "../../locales/nl.json";
|
||||
import ptTranslations from "../../locales/pt.json";
|
||||
import roTranslations from "../../locales/ro.json";
|
||||
import ruTranslations from "../../locales/ru.json";
|
||||
import svTranslations from "../../locales/sv.json";
|
||||
import uzTranslations from "../../locales/uz.json";
|
||||
import zhHansTranslations from "../../locales/zh-Hans.json";
|
||||
|
||||
@@ -22,23 +21,7 @@ i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
supportedLngs: [
|
||||
"en",
|
||||
"de",
|
||||
"it",
|
||||
"fr",
|
||||
"es",
|
||||
"ar",
|
||||
"pt",
|
||||
"ro",
|
||||
"ja",
|
||||
"ru",
|
||||
"uz",
|
||||
"zh-Hans",
|
||||
"hi",
|
||||
"nl",
|
||||
"sv",
|
||||
],
|
||||
supportedLngs: ["en", "de", "it", "fr", "es", "ar", "pt", "ro", "ja", "ru", "uz", "zh-Hans", "hi", "nl"],
|
||||
|
||||
resources: {
|
||||
en: { translation: enTranslations },
|
||||
@@ -53,7 +36,6 @@ i18n
|
||||
nl: { translation: nlTranslations },
|
||||
ru: { translation: ruTranslations },
|
||||
uz: { translation: uzTranslations },
|
||||
sv: { translation: svTranslations },
|
||||
"zh-Hans": { translation: zhHansTranslations },
|
||||
hi: { translation: hiTranslations },
|
||||
},
|
||||
|
||||
@@ -12,7 +12,6 @@ export const ZUserLocale = z.enum([
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
]);
|
||||
|
||||
export type TUserLocale = z.infer<typeof ZUserLocale>;
|
||||
|
||||
Reference in New Issue
Block a user