adds migration

This commit is contained in:
pandeymangg
2025-11-19 11:38:54 +05:30
parent 9c776c5e4e
commit 5bad0da477
2 changed files with 620 additions and 1 deletions

View File

@@ -0,0 +1,619 @@
import { createId } from "@paralleldrive/cuid2";
import { logger } from "@formbricks/logger";
import type { MigrationScript } from "../../src/scripts/migration-runner";
// Type definitions for migration
type I18nString = Record<string, string>;
interface SurveyQuestion {
id: string;
type: string;
headline?: I18nString;
logic?: SurveyLogic[];
logicFallback?: string;
buttonLabel?: I18nString;
backButtonLabel?: I18nString;
buttonUrl?: string;
buttonExternal?: boolean;
dismissButtonLabel?: I18nString;
ctaButtonLabel?: I18nString;
[key: string]: unknown;
}
interface Condition {
id: string;
leftOperand?: { value: string; type: string; meta?: Record<string, unknown> };
operator?: string;
rightOperand?: { type: string; value: string | number | string[] };
conditions?: Condition[];
connector?: string;
}
interface SurveyLogic {
id: string;
conditions: Condition;
actions: LogicAction[];
}
interface LogicAction {
id: string;
objective: string;
target?: string;
[key: string]: unknown;
}
interface Block {
id: string;
name: string;
elements: SurveyQuestion[];
logic?: SurveyLogic[];
logicFallback?: string;
buttonLabel?: I18nString;
backButtonLabel?: I18nString;
}
interface SurveyRecord {
id: string;
questions: SurveyQuestion[];
blocks?: Block[];
endings?: { id: string; [key: string]: unknown }[];
}
interface MigratedSurvey {
id: string;
blocks: Block[];
questions: SurveyQuestion[];
}
// Statistics tracking for invalid logic references
interface InvalidLogicStats {
surveysWithInvalidTargets: Set<string>;
surveysWithInvalidFallbacks: Set<string>;
totalInvalidTargets: number;
totalInvalidFallbacks: number;
firstSurveyWithInvalidTarget: string | null;
firstSurveyWithInvalidFallback: string | null;
}
// Statistics tracking for CTA migration
interface CTAMigrationStats {
totalCTAElements: number;
ctaWithExternalLink: number;
ctaWithoutExternalLink: number;
isSkippedLogicRemoved: number;
isClickedLogicKept: number;
logicRulesRemoved: number;
fieldsUpdated: number;
}
/**
* Check if a condition references a CTA element with a specific operator
*/
const conditionReferencesCTA = (
condition: Condition | null | undefined,
ctaElementId: string,
operator?: string
): boolean => {
if (!condition) return false;
if (condition.leftOperand?.value === ctaElementId) {
if (operator) {
return condition.operator === operator;
}
return true;
}
if (condition.conditions && Array.isArray(condition.conditions)) {
return condition.conditions.some((c) => conditionReferencesCTA(c, ctaElementId, operator));
}
return false;
};
/**
* Remove conditions that reference a CTA element with specific operators
*/
const removeCtaConditions = (
conditionGroup: Condition,
ctaElementId: string,
operatorsToRemove: string[]
): Condition | null => {
if (!conditionGroup.conditions) return conditionGroup;
const filteredConditions = conditionGroup.conditions.filter((condition) => {
if (condition.leftOperand?.value === ctaElementId && condition.operator) {
return !operatorsToRemove.includes(condition.operator);
}
if (condition.conditions && Array.isArray(condition.conditions)) {
const cleaned = removeCtaConditions(condition, ctaElementId, operatorsToRemove);
if (!cleaned?.conditions || cleaned.conditions.length === 0) {
return false;
}
Object.assign(condition, cleaned);
}
return true;
});
if (filteredConditions.length === 0) {
return null;
}
return {
...conditionGroup,
conditions: filteredConditions,
};
};
/**
* Migrate a single CTA question: update fields and clean logic
*/
const migrateCTAQuestion = (question: SurveyQuestion, stats: CTAMigrationStats): void => {
if (question.type !== "cta") return;
stats.totalCTAElements++;
// Check if CTA has external link
const hasExternalButton = question.buttonExternal === true && Boolean(question.buttonUrl);
if (hasExternalButton) {
stats.ctaWithExternalLink++;
// Copy buttonLabel to ctaButtonLabel
if (question.buttonLabel) {
question.ctaButtonLabel = question.buttonLabel;
stats.fieldsUpdated++;
}
// Ensure buttonUrl and buttonExternal are set
question.buttonExternal = true;
} else {
stats.ctaWithoutExternalLink++;
// CTA without external link: remove buttonExternal and buttonUrl
delete question.buttonExternal;
delete question.buttonUrl;
}
// Remove old fields that are no longer used
delete question.buttonLabel;
delete question.dismissButtonLabel;
};
/**
* Clean CTA logic from a question's logic array
*/
const cleanCTALogicFromQuestion = (
question: SurveyQuestion,
ctaQuestions: Map<string, boolean>,
stats: CTAMigrationStats
): void => {
if (!question.logic || question.logic.length === 0) return;
const cleanedLogic: SurveyLogic[] = [];
question.logic.forEach((logicRule) => {
let shouldKeepRule = true;
let modifiedConditions = logicRule.conditions;
// Check each CTA question
ctaQuestions.forEach((hasExternalButton, ctaId) => {
if (!hasExternalButton) {
// CTA without external button - remove ALL conditions referencing this CTA
if (conditionReferencesCTA(modifiedConditions, ctaId)) {
const cleanedConditions = removeCtaConditions(modifiedConditions, ctaId, [
"isClicked",
"isSkipped",
]);
if (!cleanedConditions?.conditions || cleanedConditions.conditions.length === 0) {
shouldKeepRule = false;
stats.logicRulesRemoved++;
} else {
modifiedConditions = cleanedConditions;
}
}
} else {
// CTA with external button - remove isSkipped, keep isClicked
const hadIsSkipped = conditionReferencesCTA(modifiedConditions, ctaId, "isSkipped");
const hadIsClicked = conditionReferencesCTA(modifiedConditions, ctaId, "isClicked");
if (hadIsSkipped) {
stats.isSkippedLogicRemoved++;
const cleanedConditions = removeCtaConditions(modifiedConditions, ctaId, ["isSkipped"]);
if (!cleanedConditions?.conditions || cleanedConditions.conditions.length === 0) {
shouldKeepRule = false;
stats.logicRulesRemoved++;
} else {
modifiedConditions = cleanedConditions;
}
}
if (hadIsClicked && shouldKeepRule) {
stats.isClickedLogicKept++;
// Keep isClicked as-is
}
}
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- shouldKeepRule can be modified in loop
if (shouldKeepRule) {
cleanedLogic.push({
...logicRule,
conditions: modifiedConditions,
});
}
});
if (cleanedLogic.length === 0) {
delete question.logic;
} else {
question.logic = cleanedLogic;
}
};
/**
* Process all CTA questions in a survey: migrate fields and clean logic
*/
const processCTAQuestions = (questions: SurveyQuestion[], stats: CTAMigrationStats): void => {
// Build map of CTA question IDs to their external button status
const ctaQuestions = new Map<string, boolean>();
questions.forEach((question) => {
if (question.type === "cta") {
const hasExternalButton = question.buttonExternal === true && Boolean(question.buttonUrl);
ctaQuestions.set(question.id, hasExternalButton);
}
});
if (ctaQuestions.size === 0) return;
// First pass: migrate CTA question fields
questions.forEach((question) => {
migrateCTAQuestion(question, stats);
});
// Second pass: clean CTA logic from ALL questions
questions.forEach((question) => {
cleanCTALogicFromQuestion(question, ctaQuestions, stats);
});
};
/**
* Generate block name from question headline or use index-based fallback
* @param questionIdx - The 0-based index of the question in the survey
* @returns Block name (e.g., "Block 1", "Block 2")
*/
const getBlockName = (questionIdx: number): string => {
return `Block ${String(questionIdx + 1)}`;
};
/**
* Update logic actions: convert jumpToQuestion to jumpToBlock with new block IDs
* @param actions - Array of logic actions
* @param questionIdToBlockId - Map of question IDs to new block IDs
* @param endingIds - Set of valid ending card IDs
* @param surveyId - Survey ID for logging purposes
* @param stats - Statistics tracker for invalid logic references
* @returns Updated actions array with jumpToBlock objectives
*/
const updateLogicActions = (
actions: LogicAction[],
questionIdToBlockId: Map<string, string>,
endingIds: Set<string>,
surveyId: string,
stats: InvalidLogicStats
): LogicAction[] => {
return actions.map((action) => {
if (action.objective === "jumpToQuestion") {
const target = action.target ?? "";
const blockId = questionIdToBlockId.get(target);
if (blockId) {
// Target is a question ID - convert to block ID
return {
...action,
objective: "jumpToBlock",
target: blockId,
};
}
// Check if target is a valid ending card ID
if (endingIds.has(target)) {
// Target is an ending card - keep it as is but change objective
return {
...action,
objective: "jumpToBlock",
target,
};
}
// Target is neither a question nor an ending card - track statistics
stats.totalInvalidTargets++;
stats.surveysWithInvalidTargets.add(surveyId);
stats.firstSurveyWithInvalidTarget ??= surveyId;
return {
...action,
objective: "jumpToBlock",
target,
};
}
// calculate and requireAnswer stay unchanged
return action;
});
};
/**
* Update logic fallback: convert question ID to block ID
* @param fallback - The fallback question ID or ending card ID
* @param questionIdToBlockId - Map of question IDs to new block IDs
* @param endingIds - Set of valid ending card IDs
* @param surveyId - Survey ID for logging purposes
* @param stats - Statistics tracker for invalid logic references
* @returns Updated fallback with block ID, unchanged ending card ID, or undefined if invalid
*/
const updateLogicFallback = (
fallback: string,
questionIdToBlockId: Map<string, string>,
endingIds: Set<string>,
surveyId: string,
stats: InvalidLogicStats
): string | undefined => {
const blockId = questionIdToBlockId.get(fallback);
if (blockId) {
// Fallback is a question ID - convert to block ID
return blockId;
}
// Check if fallback is a valid ending card ID
if (endingIds.has(fallback)) {
// Fallback is an ending card - keep it as is
return fallback;
}
// Fallback is neither a question nor an ending card - track statistics and remove
stats.totalInvalidFallbacks++;
stats.surveysWithInvalidFallbacks.add(surveyId);
stats.firstSurveyWithInvalidFallback ??= surveyId;
return undefined; // Return undefined to remove the invalid fallback
};
/**
* Migrate a survey from questions to blocks structure
* Each question becomes a block with a single element
* @param survey - Survey record with questions
* @param createIdFn - Function to generate CUIDs for blocks
* @param invalidLogicStats - Statistics tracker for invalid logic references
* @param ctaStats - Statistics tracker for CTA migration
* @returns Migrated survey with blocks and empty questions array
*/
const migrateQuestionsSurveyToBlocks = (
survey: SurveyRecord,
createIdFn: () => string,
invalidLogicStats: InvalidLogicStats,
ctaStats: CTAMigrationStats
): MigratedSurvey => {
// Skip if no questions
if (survey.questions.length === 0) {
return { ...survey, blocks: survey.blocks ?? [], questions: [] };
}
// STEP 1: Process CTA questions FIRST (before converting to blocks)
processCTAQuestions(survey.questions, ctaStats);
// Create set of valid ending card IDs for validation
const endingIds = new Set<string>((survey.endings ?? []).map((ending) => ending.id));
// Phase 1: Create blocks and ID mapping
const questionIdToBlockId = new Map<string, string>();
const blocks: Block[] = [];
for (let i = 0; i < survey.questions.length; i++) {
const question = survey.questions[i];
const blockId = createIdFn();
questionIdToBlockId.set(question.id, blockId);
// Extract logic from question level
const { logic, logicFallback, buttonLabel, backButtonLabel, ...baseElement } = question;
blocks.push({
id: blockId,
name: getBlockName(i),
elements: [baseElement],
buttonLabel,
backButtonLabel,
logic, // Will update in Phase 2
logicFallback, // Will update in Phase 2
});
}
// Phase 2: Update all logic references
for (const block of blocks) {
if (block.logic && block.logic.length > 0) {
block.logic = block.logic.map((item) => ({
...item,
actions: updateLogicActions(
item.actions,
questionIdToBlockId,
endingIds,
survey.id,
invalidLogicStats
),
}));
}
if (block.logicFallback) {
block.logicFallback = updateLogicFallback(
block.logicFallback,
questionIdToBlockId,
endingIds,
survey.id,
invalidLogicStats
);
}
}
return {
...survey,
blocks,
questions: [],
};
};
export const migrateQuestionsToBlocks: MigrationScript = {
type: "data",
id: "wsm6h7c8jt086g96ob7wda14",
name: "20251118032116_migrate_questions_to_blocks",
run: async ({ tx }) => {
// Initialize statistics trackers
const invalidLogicStats: InvalidLogicStats = {
surveysWithInvalidTargets: new Set(),
surveysWithInvalidFallbacks: new Set(),
totalInvalidTargets: 0,
totalInvalidFallbacks: 0,
firstSurveyWithInvalidTarget: null,
firstSurveyWithInvalidFallback: null,
};
const ctaStats: CTAMigrationStats = {
totalCTAElements: 0,
ctaWithExternalLink: 0,
ctaWithoutExternalLink: 0,
isSkippedLogicRemoved: 0,
isClickedLogicKept: 0,
logicRulesRemoved: 0,
fieldsUpdated: 0,
};
// 1. Query surveys with questions (also fetch endings for validation)
const surveys = await tx.$queryRaw<SurveyRecord[]>`
SELECT id, questions, blocks, endings
FROM "Survey"
WHERE jsonb_array_length(questions) > 0
`;
if (surveys.length === 0) {
logger.info("No surveys found that need migration");
return;
}
logger.info(`Found ${surveys.length.toString()} surveys to migrate`);
// 2. Process each survey
const updates: { id: string; blocks: Block[]; questions: SurveyQuestion[] }[] = [];
let failedCount = 0;
for (const survey of surveys) {
try {
const migrated = migrateQuestionsSurveyToBlocks(survey, createId, invalidLogicStats, ctaStats);
updates.push({
id: migrated.id,
blocks: migrated.blocks,
questions: [],
});
} catch (error) {
failedCount++;
logger.error(error, `Failed to migrate survey ${survey.id}`);
}
}
if (updates.length === 0) {
logger.error(`All ${failedCount.toString()} surveys failed migration`);
throw new Error("Migration failed for all surveys");
}
logger.info(
`Successfully processed ${updates.length.toString()} surveys, ${failedCount.toString()} failed`
);
// 3. Update surveys individually for safety (avoids SQL injection risks with complex JSONB arrays)
let updatedCount = 0;
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 = $2::jsonb
WHERE id = $3`,
JSON.stringify(update.blocks),
JSON.stringify(update.questions),
update.id
);
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`);
failedCount++;
}
}
logger.info(`Migration complete: ${updatedCount.toString()} surveys migrated to blocks`);
if (failedCount > 0) {
logger.warn(`Warning: ${failedCount.toString()} surveys failed and need manual review`);
}
// 4. Log CTA migration statistics
if (ctaStats.totalCTAElements > 0) {
logger.info("=== CTA Migration Summary ===");
logger.info(`Total CTA elements processed: ${ctaStats.totalCTAElements.toString()}`);
logger.info(` - With external link: ${ctaStats.ctaWithExternalLink.toString()}`);
logger.info(` - Without external link: ${ctaStats.ctaWithoutExternalLink.toString()}`);
logger.info(`Fields updated (buttonLabel → ctaButtonLabel): ${ctaStats.fieldsUpdated.toString()}`);
logger.info(`isSkipped logic conditions removed: ${ctaStats.isSkippedLogicRemoved.toString()}`);
logger.info(`isClicked logic conditions kept: ${ctaStats.isClickedLogicKept.toString()}`);
logger.info(`Logic rules removed (became empty): ${ctaStats.logicRulesRemoved.toString()}`);
}
// 5. Log invalid logic statistics summary
if (invalidLogicStats.totalInvalidTargets > 0 || invalidLogicStats.totalInvalidFallbacks > 0) {
logger.warn("=== Invalid Logic References Summary ===");
if (invalidLogicStats.totalInvalidTargets > 0) {
logger.warn(
`Found ${invalidLogicStats.totalInvalidTargets.toString()} invalid logic jump targets in ${invalidLogicStats.surveysWithInvalidTargets.size.toString()} surveys`
);
if (invalidLogicStats.firstSurveyWithInvalidTarget) {
logger.warn(
`Example survey with invalid target: ${invalidLogicStats.firstSurveyWithInvalidTarget} (targets kept as-is, may cause validation errors)`
);
}
}
if (invalidLogicStats.totalInvalidFallbacks > 0) {
logger.warn(
`Found ${invalidLogicStats.totalInvalidFallbacks.toString()} invalid logic fallbacks in ${invalidLogicStats.surveysWithInvalidFallbacks.size.toString()} surveys`
);
if (invalidLogicStats.firstSurveyWithInvalidFallback) {
logger.warn(
`Example survey with invalid fallback: ${invalidLogicStats.firstSurveyWithInvalidFallback} (fallbacks removed, will use sequential flow)`
);
}
}
logger.warn(
"These surveys may need manual review. Invalid targets were kept as-is, invalid fallbacks were removed."
);
} else {
logger.info("✓ No invalid logic references found - all surveys migrated cleanly!");
}
logger.info("✅ Migration completed successfully!");
},
};

View File

@@ -29,7 +29,7 @@
"build": "pnpm generate && vite build",
"dev": "vite build --watch",
"db:migrate:deploy": "env DATABASE_URL=\"${MIGRATE_DATABASE_URL:-$DATABASE_URL}\" node ./dist/scripts/apply-migrations.js",
"db:migrate:dev": "dotenv -e ../../.env -- sh -c \"pnpm prisma generate && node ./dist/scripts/apply-migrations.js\"",
"db:migrate:dev": "pnpm build && dotenv -e ../../.env -- sh -c \"pnpm prisma generate && node ./dist/scripts/apply-migrations.js\"",
"db:create-saml-database:deploy": "env SAML_DATABASE_URL=\"${SAML_DATABASE_URL}\" node ./dist/scripts/create-saml-database.js",
"db:create-saml-database:dev": "dotenv -e ../../.env -- node ./dist/scripts/create-saml-database.js",
"db:push": "prisma db push --accept-data-loss",