feat: new data migrations approach (#4466)

Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Anshuman Pandey
2024-12-17 19:12:23 +05:30
committed by GitHub
parent 9910cafe78
commit f49375dce4
158 changed files with 1336 additions and 4499 deletions
+2 -1
View File
@@ -60,7 +60,8 @@ jobs:
- name: Apply Prisma Migrations
run: |
pnpm prisma migrate deploy
# pnpm prisma migrate deploy
pnpm db:migrate:dev
- name: Run App
run: |
+2
View File
@@ -60,3 +60,5 @@ packages/lib/uploads
# js compiled assets
apps/web/public/js
packages/database/migrations
@@ -8,6 +8,96 @@ export const metadata = {
# Migration Guide
## v3.0
<Note>
**Don't upgrade to 3.0 if you need SSO and user identification**
With Formbricks 3.0, we're making some strategic changes to ensure long-term sustainability while keeping our core commitment to open source. While the Community Edition <a href="https://formbricks.com/blog/formbricks-3-0">got more powerful</a>, we're moving <a href="/docs/self-hosting/license">some advanced features</a> to the Enterprise Edition.
If you update to 3.0 and run the data migration, there is no way back to 2.7.2 - if you need to use SSO or one of the other previously free features, either stick with 2.7.x or <a href="mailto:hola@formbricks.com">reach out</a> for a custom quote.
</Note>
<Note>
This major release introduces a new improved approach for data migrations. If you are on a version older
than v2.7, you need to migrate step-by-step through the earlier versions first (e.g. 2.4 → 2.5 → 2.6 → 2.7).
After you have reached v2.7, you can upgrade directly to any v3.x and future release without performing each
intermediate migration.
</Note>
### Steps to Migrate
This guide is for users who are self-hosting Formbricks using our one-click setup. If you are using a different setup, you might adjust the commands accordingly.
To run all these steps, please navigate to the `formbricks` folder where your `docker-compose.yml` file is located.
1. **Backup your Database**: This is a crucial step. Please make sure to backup your database before proceeding with the upgrade. You can use the following command to backup your database:
<Col>
<CodeGroup title="Backup Postgres">
```bash
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v3.0_$(date +%Y%m%d_%H%M%S).dump
```
</CodeGroup>
</Col>
<Note>
If you run into “No such container”, use `docker ps` to find your container name, e.g.
`formbricks_postgres_1`.
</Note>
<Note>
If you prefer storing the backup as an `*.sql` file remove the `-Fc` (custom format) option. In case of a
restore scenario you will need to use `psql` then with an empty `formbricks` database.
</Note>
2. Pull the latest version of Formbricks:
<Col>
<CodeGroup title="Stop the containers">
```bash
docker compose pull
```
</CodeGroup>
</Col>
3. Stop the running Formbricks instance & remove the related containers:
<Col>
<CodeGroup title="Stop the containers">
```bash
docker compose down
```
</CodeGroup>
</Col>
4. Restarting the containers with the latest version of Formbricks:
<Col>
<CodeGroup title="Restart the containers">
```bash
docker compose up -d
```
</CodeGroup>
</Col>
When you start the latest version of Formbricks, it will automatically detect and run any necessary data migrations during startup. There is no need to run any manual migration steps or pull any separate migration images.
5. Access your updated instance
Once the containers are up and running, simply navigate to the same URL as before to access your fully migrated Formbricks instance.
Thats it! The new workflow ensures that your Formbricks instance will always remain up-to-date with the latest schema changes as soon as you run the updated container.
## v2.7
<Note>
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
"dev": "next dev --turbopack --port 3001",
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start",
"lint": "next lint",
+13 -4
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine AS base
FROM node:22-alpine3.20 AS base
#
## step 1: Prune monorepo
@@ -78,12 +78,21 @@ COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/we
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migrations ./packages/database/migrations
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
# Copy Prisma-specific generated files
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
COPY /docker/cronjobs /app/docker/cronjobs
# Install Prisma globally
RUN PRISMA_VERSION=$(cat prisma_version.txt) && npm install -g prisma@$PRISMA_VERSION
# Copy only @paralleldrive/cuid2 and @noble/hashes
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
RUN npm install -g tsx typescript prisma
EXPOSE 3000
ENV HOSTNAME "0.0.0.0"
+2 -2
View File
@@ -15,7 +15,6 @@
"build:dev": "turbo run build:dev",
"db:migrate:dev": "turbo run db:migrate:dev",
"db:migrate:deploy": "turbo run db:migrate:deploy",
"db:migrate:vercel": "turbo run db:migrate:vercel",
"db:start": "turbo run db:start",
"db:push": "turbo run db:push",
"go": "turbo run go --concurrency 20",
@@ -29,7 +28,8 @@
"test": "turbo run test --no-cache",
"test:e2e": "playwright test",
"prepare": "husky install",
"storybook": "turbo run storybook"
"storybook": "turbo run storybook",
"fb-migrate-dev": "pnpm --filter @formbricks/database create-migration && pnpm prisma generate"
},
"devDependencies": {
"@formbricks/eslint-config": "workspace:*",
+3
View File
@@ -4,4 +4,7 @@ module.exports = {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
rules: {
"no-console": "off",
},
};
@@ -1,110 +0,0 @@
import { createId } from "@paralleldrive/cuid2";
import { PrismaClient } from "@prisma/client";
import {
TBaseFilter,
TBaseFilters,
TSegmentAttributeFilter,
TSegmentPersonFilter,
} from "@formbricks/types/segment";
const prisma = new PrismaClient();
const main = async () => {
await prisma.$transaction(async (tx) => {
const allSurveysWithAttributeFilters = await prisma.survey.findMany({
where: {
attributeFilters: {
some: {},
},
},
include: {
attributeFilters: { include: { attributeClass: true } },
},
});
if (!allSurveysWithAttributeFilters?.length) {
// stop the migration if there are no surveys with attribute filters
return;
}
allSurveysWithAttributeFilters.forEach(async (survey) => {
const { attributeFilters } = survey;
// if there are no attribute filters, we can skip this survey
if (!attributeFilters?.length) {
return;
}
// from these attribute filters, we need to create user segments
// each attribute filter will be a filter in the user segment
// all the filters will be joined by AND
// the user segment will be private
const filters: TBaseFilters = attributeFilters.map((filter, idx) => {
const { attributeClass } = filter;
let resource: TSegmentAttributeFilter | TSegmentPersonFilter;
// if the attribute class is userId, we need to create a user segment with the person filter
if (attributeClass.name === "userId" && attributeClass.type === "automatic") {
resource = {
id: createId(),
root: {
type: "person",
personIdentifier: "userId",
},
qualifier: {
operator: filter.condition,
},
value: filter.value,
};
} else {
resource = {
id: createId(),
root: {
type: "attribute",
attributeClassName: attributeClass.name,
},
qualifier: {
operator: filter.condition,
},
value: filter.value,
};
}
const attributeSegment: TBaseFilter = {
id: filter.id,
connector: idx === 0 ? null : "and",
resource,
};
return attributeSegment;
});
await tx.segment.create({
data: {
title: `${survey.id}`,
description: "",
isPrivate: true,
filters,
surveys: {
connect: {
id: survey.id,
},
},
environment: {
connect: {
id: survey.environmentId,
},
},
},
});
});
// delete all attribute filters
await tx.surveyAttributeFilter.deleteMany({});
});
};
main()
.catch(async (e) => {
console.error(e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());
@@ -1,44 +0,0 @@
/* eslint-disable -- leacy support workaround for now to avoid rewrite after eslint rules have been changed */
// migration script to translate surveys where thankYouCard buttonLabel is a string or question subheaders are strings
import { PrismaClient } from "@prisma/client";
import { hasStringSubheaders, translateSurvey } from "./lib/i18n";
const prisma = new PrismaClient();
const main = async () => {
await prisma.$transaction(
async (tx) => {
// Translate Surveys
const surveys: any = await tx.survey.findMany({
select: {
id: true,
questions: true,
thankYouCard: true,
welcomeCard: true,
},
});
for (const survey of surveys) {
if (typeof survey.thankYouCard.buttonLabel === "string" || hasStringSubheaders(survey.questions)) {
const translatedSurvey = translateSurvey(survey, []);
// Save the translated survey
await tx.survey.update({
where: { id: survey.id },
data: { ...translatedSurvey },
});
}
}
},
{
timeout: 50000,
}
);
};
main()
.catch(async (e) => {
console.error(e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());
@@ -1,58 +0,0 @@
/* eslint-disable -- leacy support workaround for now to avoid rewrite after eslint rules have been changed */
// migration script to convert range field in rating question from string to number
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const main = async () => {
await prisma.$transaction(
async (tx) => {
const surveys = await tx.survey.findMany({
select: {
id: true,
questions: true,
},
});
if (surveys.length === 0) {
// stop the migration if there are no surveys
return;
}
for (const survey of surveys) {
let updateNeeded = false;
const updatedSurvey = structuredClone(survey) as any;
if (updatedSurvey.questions.length > 0) {
for (const question of updatedSurvey.questions) {
if (question.type === "rating" && typeof question.range === "string") {
const parsedRange = parseInt(question.range);
if (!isNaN(parsedRange)) {
updateNeeded = true;
question.range = parsedRange;
} else {
throw new Error(`Invalid range value for question Id ${question.id}: ${question.range}`);
}
}
}
}
if (updateNeeded) {
// Save the translated survey
await tx.survey.update({
where: { id: survey.id },
data: { ...updatedSurvey },
});
}
}
},
{
timeout: 50000,
}
);
};
main()
.catch(async (e) => {
console.error(e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());
@@ -1,52 +0,0 @@
/* eslint-disable -- leacy support workaround for now to avoid rewrite after eslint rules have been changed */
// migration script to add empty strings to welcome card headline in default language, if it does not exist
// WelcomeCard.headline = {} -> WelcomeCard.headline = {"default":""}
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const main = async () => {
await prisma.$transaction(
async (tx) => {
const surveys = await tx.survey.findMany({
select: {
id: true,
welcomeCard: true,
},
});
if (surveys.length === 0) {
// stop the migration if there are no surveys
console.log("Stopping migration, no surveys found");
return;
}
let count = 0;
for (const survey of surveys) {
const updatedSurvey = structuredClone(survey) as any;
if (
updatedSurvey.welcomeCard &&
updatedSurvey.welcomeCard.headline &&
Object.keys(updatedSurvey.welcomeCard.headline).length === 0
) {
updatedSurvey.welcomeCard.headline["default"] = "";
count++;
await tx.survey.update({
where: { id: survey.id },
data: { ...updatedSurvey },
});
}
}
console.log(count, "surveys transformed");
},
{
timeout: 50000,
}
);
};
main()
.catch(async (e) => {
console.error(e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());
@@ -1,102 +0,0 @@
/* eslint-disable -- leacy support workaround for now to avoid rewrite after eslint rules have been changed */
import { PrismaClient } from "@prisma/client";
import { AttributeType } from "@prisma/client";
import { translateSurvey } from "./lib/i18n";
const prisma = new PrismaClient();
const main = async () => {
await prisma.$transaction(
async (tx) => {
// Translate Surveys
const surveys = await tx.survey.findMany({
select: {
id: true,
questions: true,
thankYouCard: true,
welcomeCard: true,
},
});
if (!surveys) {
// stop the migration if there are no surveys
console.log("No survey found");
return;
}
console.log("Translating surveys");
for (const survey of surveys) {
if (survey.questions.length > 0 && typeof survey.questions[0].headline === "string") {
const translatedSurvey = translateSurvey(survey, []);
// Save the translated survey
await tx.survey.update({
where: { id: survey.id },
data: { ...translatedSurvey },
});
}
}
console.log("Survey translation completed");
// Add language attributeClass
const environments = await tx.environment.findMany({
select: {
id: true,
attributeClasses: true,
},
});
if (!environments) {
console.log("No environments found");
// stop the migration if there are no environments
return;
}
console.log("Adding language attribute class");
for (const environment of environments) {
const languageAttributeClass = environment.attributeClasses.find((attributeClass) => {
return attributeClass.name === "language";
});
if (languageAttributeClass) {
// Update existing attributeClass if needed
if (
languageAttributeClass.type === AttributeType.automatic &&
languageAttributeClass.description === "The language used by the person"
) {
continue;
}
await tx.attributeClass.update({
where: { id: languageAttributeClass.id },
data: {
type: AttributeType.automatic,
description: "The language used by the person",
},
});
} else {
// Create new attributeClass
await tx.attributeClass.create({
data: {
name: "language",
type: AttributeType.automatic,
description: "The language used by the person",
environment: {
connect: { id: environment.id },
},
},
});
}
}
console.log("Adding language attribute class finished");
},
{
timeout: 50000,
}
);
};
main()
.catch(async (e) => {
console.error(e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());
@@ -1,262 +0,0 @@
/* eslint-disable -- leacy support workaround for now to avoid rewrite after eslint rules have been changed */
import { type TLanguage } from "@formbricks/types/project";
import {
type TI18nString,
type TSurveyCTAQuestion,
type TSurveyConsentQuestion,
type TSurveyMultipleChoiceQuestion,
type TSurveyNPSQuestion,
type TSurveyOpenTextQuestion,
type TSurveyQuestion,
type TSurveyQuestionChoice,
type TSurveyQuestions,
type TSurveyRatingQuestion,
type TSurveyWelcomeCard,
ZSurveyCTAQuestion,
ZSurveyCalQuestion,
ZSurveyConsentQuestion,
ZSurveyFileUploadQuestion,
ZSurveyMultipleChoiceQuestion,
ZSurveyNPSQuestion,
ZSurveyOpenTextQuestion,
ZSurveyPictureSelectionQuestion,
ZSurveyQuestion,
ZSurveyRatingQuestion,
ZSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
// Helper function to create an i18nString from a regular string.
export const createI18nString = (text: string | TI18nString, languages: string[]): TI18nString => {
if (typeof text === "object") {
// It's already an i18n object, so clone it
const i18nString: TI18nString = structuredClone(text);
// Add new language keys with empty strings if they don't exist
languages?.forEach((language) => {
if (!(language in i18nString)) {
i18nString[language] = "";
}
});
// Remove language keys that are not in the languages array
Object.keys(i18nString).forEach((key) => {
if (key !== "default" && languages && !languages.includes(key)) {
delete i18nString[key];
}
});
return i18nString;
} else {
// It's a regular string, so create a new i18n object
const i18nString: TI18nString = {
["default"]: text, // Type assertion to assure TypeScript `text` is a string
};
// Initialize all provided languages with empty strings
languages?.forEach((language) => {
if (language !== "default") {
i18nString[language] = "";
}
});
return i18nString;
}
};
// Function to translate a choice label
const translateChoice = (choice: TSurveyQuestionChoice, languages: string[]): TSurveyQuestionChoice => {
if (typeof choice.label !== "undefined") {
return {
...choice,
label: createI18nString(choice.label, languages),
};
} else {
return {
...choice,
label: choice.label,
};
}
};
export const translateWelcomeCard = (
welcomeCard: TSurveyWelcomeCard,
languages: string[]
): TSurveyWelcomeCard => {
const clonedWelcomeCard = structuredClone(welcomeCard);
if (typeof welcomeCard.headline !== "undefined") {
clonedWelcomeCard.headline = createI18nString(welcomeCard.headline ?? "", languages);
}
if (typeof welcomeCard.html !== "undefined") {
clonedWelcomeCard.html = createI18nString(welcomeCard.html ?? "", languages);
}
if (typeof welcomeCard.buttonLabel !== "undefined") {
clonedWelcomeCard.buttonLabel = createI18nString(clonedWelcomeCard.buttonLabel ?? "", languages);
}
return ZSurveyWelcomeCard.parse(clonedWelcomeCard);
};
const translateThankYouCard = (thankYouCard: any, languages: string[]): any => {
const clonedThankYouCard = structuredClone(thankYouCard);
if (typeof thankYouCard.headline !== "undefined") {
clonedThankYouCard.headline = createI18nString(thankYouCard.headline ?? "", languages);
}
if (typeof thankYouCard.subheader !== "undefined") {
clonedThankYouCard.subheader = createI18nString(thankYouCard.subheader ?? "", languages);
}
if (typeof clonedThankYouCard.buttonLabel !== "undefined") {
clonedThankYouCard.buttonLabel = createI18nString(thankYouCard.buttonLabel ?? "", languages);
}
return clonedThankYouCard;
};
// Function that will translate a single question
const translateQuestion = (question: TSurveyQuestion, languages: string[]): TSurveyQuestion => {
// Clone the question to avoid mutating the original
const clonedQuestion = structuredClone(question);
//common question properties
if (typeof question.headline !== "undefined") {
clonedQuestion.headline = createI18nString(question.headline ?? "", languages);
}
if (typeof question.subheader !== "undefined") {
clonedQuestion.subheader = createI18nString(question.subheader ?? "", languages);
}
if (typeof question.buttonLabel !== "undefined") {
clonedQuestion.buttonLabel = createI18nString(question.buttonLabel ?? "", languages);
}
if (typeof question.backButtonLabel !== "undefined") {
clonedQuestion.backButtonLabel = createI18nString(question.backButtonLabel ?? "", languages);
}
switch (question.type) {
case "openText":
if (typeof question.placeholder !== "undefined") {
(clonedQuestion as TSurveyOpenTextQuestion).placeholder = createI18nString(
question.placeholder ?? "",
languages
);
}
return ZSurveyOpenTextQuestion.parse(clonedQuestion);
case "multipleChoiceSingle":
case "multipleChoiceMulti":
(clonedQuestion as TSurveyMultipleChoiceQuestion).choices = question.choices.map((choice) => {
return translateChoice(choice, languages);
});
if (typeof (clonedQuestion as TSurveyMultipleChoiceQuestion).otherOptionPlaceholder !== "undefined") {
(clonedQuestion as TSurveyMultipleChoiceQuestion).otherOptionPlaceholder = createI18nString(
question.otherOptionPlaceholder ?? "",
languages
);
}
return ZSurveyMultipleChoiceQuestion.parse(clonedQuestion);
case "cta":
if (typeof question.dismissButtonLabel !== "undefined") {
(clonedQuestion as TSurveyCTAQuestion).dismissButtonLabel = createI18nString(
question.dismissButtonLabel ?? "",
languages
);
}
if (typeof question.html !== "undefined") {
(clonedQuestion as TSurveyCTAQuestion).html = createI18nString(question.html ?? "", languages);
}
return ZSurveyCTAQuestion.parse(clonedQuestion);
case "consent":
if (typeof question.html !== "undefined") {
(clonedQuestion as TSurveyConsentQuestion).html = createI18nString(question.html ?? "", languages);
}
if (typeof question.label !== "undefined") {
(clonedQuestion as TSurveyConsentQuestion).label = createI18nString(question.label ?? "", languages);
}
return ZSurveyConsentQuestion.parse(clonedQuestion);
case "nps":
if (typeof question.lowerLabel !== "undefined") {
(clonedQuestion as TSurveyNPSQuestion).lowerLabel = createI18nString(
question.lowerLabel ?? "",
languages
);
}
if (typeof question.upperLabel !== "undefined") {
(clonedQuestion as TSurveyNPSQuestion).upperLabel = createI18nString(
question.upperLabel ?? "",
languages
);
}
return ZSurveyNPSQuestion.parse(clonedQuestion);
case "rating":
if (typeof question.lowerLabel !== "undefined") {
(clonedQuestion as TSurveyRatingQuestion).lowerLabel = createI18nString(
question.lowerLabel ?? "",
languages
);
}
if (typeof question.upperLabel !== "undefined") {
(clonedQuestion as TSurveyRatingQuestion).upperLabel = createI18nString(
question.upperLabel ?? "",
languages
);
}
const range = question.range;
if (typeof range === "string") {
const parsedRange = parseInt(range);
// @ts-expect-error
clonedQuestion.range = parsedRange;
}
return ZSurveyRatingQuestion.parse(clonedQuestion);
case "fileUpload":
return ZSurveyFileUploadQuestion.parse(clonedQuestion);
case "pictureSelection":
return ZSurveyPictureSelectionQuestion.parse(clonedQuestion);
case "cal":
return ZSurveyCalQuestion.parse(clonedQuestion);
default:
return ZSurveyQuestion.parse(clonedQuestion);
}
};
export const extractLanguageIds = (languages: TLanguage[]): string[] => {
return languages.map((language) => language.id);
};
// Function to translate an entire survey (from old survey format to new survey format)
export const translateSurvey = (survey: any, languageCodes: string[]) => {
const translatedQuestions = survey.questions.map((question: any) => {
return translateQuestion(question, languageCodes);
});
const translatedWelcomeCard = translateWelcomeCard(survey.welcomeCard, languageCodes);
const translatedThankYouCard = translateThankYouCard(survey.thankYouCard, languageCodes);
const translatedSurvey = structuredClone(survey);
return {
...translatedSurvey,
questions: translatedQuestions,
welcomeCard: translatedWelcomeCard,
thankYouCard: translatedThankYouCard,
};
};
export const hasStringSubheaders = (questions: TSurveyQuestions): boolean => {
for (const question of questions) {
if (typeof question.subheader !== "undefined") {
return true;
}
}
return false;
};
@@ -1,62 +0,0 @@
import { Prisma, PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
await prisma.$transaction(
async (tx) => {
const startTime = Date.now();
console.log("Starting data migration for styling fixes...");
const surveysWithProductOverwrites = await tx.survey.findMany({
where: {
productOverwrites: {
not: Prisma.JsonNull,
},
},
});
console.log(`Found ${surveysWithProductOverwrites.length} surveys with product overwrites to process.`);
for (const survey of surveysWithProductOverwrites) {
if (survey.productOverwrites) {
const { brandColor, highlightBorderColor, ...rest } = survey.productOverwrites;
if (!brandColor && !highlightBorderColor) {
continue;
}
await tx.survey.update({
where: {
id: survey.id,
},
data: {
styling: {
...(survey.styling ?? {}),
...(brandColor && { brandColor: { light: brandColor } }),
...(highlightBorderColor && { highlightBorderColor: { light: highlightBorderColor } }),
...((brandColor || highlightBorderColor || Object.keys(survey.styling ?? {}).length > 0) && {
overwriteThemeStyling: true,
}),
},
productOverwrites: {
...rest,
},
},
});
}
}
const endTime = Date.now();
console.log(`Data migration for styling fixes completed in ${(endTime - startTime) / 1000} seconds.`);
},
{ timeout: 50000 }
);
}
main()
.catch(async (e) => {
console.error(e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());
@@ -1,140 +0,0 @@
import { PrismaClient } from "@prisma/client";
const DEFAULT_BRAND_COLOR = "#64748b";
const DEFAULT_STYLING = {
allowStyleOverwrite: true,
};
const prisma = new PrismaClient();
const main = async () => {
await prisma.$transaction(
async (tx) => {
// product table with brand color and the highlight border color (if available)
// styling object needs to be created for each product
const products = await tx.product.findMany({
include: { environments: { include: { surveys: true } } },
});
if (!products) {
// something went wrong, could not find any products
return;
}
if (products.length) {
for (const product of products) {
// no migration needed
// 1. product's brandColor is equal to the default one
// 2. product's styling object is equal the default one
// 3. product has no highlightBorderColor
if (
product.brandColor === DEFAULT_BRAND_COLOR &&
JSON.stringify(product.styling) === JSON.stringify(DEFAULT_STYLING) &&
!product.highlightBorderColor
) {
continue;
}
await tx.product.update({
where: {
id: product.id,
},
data: {
styling: {
...product.styling,
// only if the brand color is not null and not equal to the default one, we need to update the styling object. Otherwise, we'll just use the default value
...(product.brandColor &&
product.brandColor !== DEFAULT_BRAND_COLOR && {
brandColor: { light: product.brandColor },
}),
...(product.highlightBorderColor && {
highlightBorderColor: {
light: product.highlightBorderColor,
},
}),
},
brandColor: null,
highlightBorderColor: null,
},
});
// for each survey in the product, we need to update the stying object with the brand color and the highlight border color
for (const environment of product.environments) {
for (const survey of environment.surveys) {
const { styling } = product;
const { brandColor, highlightBorderColor } = styling;
if (!survey.styling) {
continue;
}
const { styling: surveyStyling } = survey;
const { hideProgressBar } = surveyStyling;
await tx.survey.update({
where: {
id: survey.id,
},
data: {
styling: {
...(survey.styling ?? {}),
...(brandColor &&
brandColor.light && {
brandColor: { light: brandColor.light },
}),
...(highlightBorderColor?.light && {
highlightBorderColor: {
light: highlightBorderColor.light,
},
}),
// if the previous survey had the hideProgressBar set to true, we need to update the styling object with overwriteThemeStyling set to true
...(hideProgressBar && {
overwriteThemeStyling: true,
}),
},
},
});
// if the survey has product overwrites, we need to update the styling object with the brand color and the highlight border color
if (survey.productOverwrites) {
const { brandColor, highlightBorderColor, ...rest } = survey.productOverwrites;
await tx.survey.update({
where: {
id: survey.id,
},
data: {
styling: {
...(survey.styling ?? {}),
...(brandColor && { brandColor: { light: brandColor } }),
...(highlightBorderColor && { highlightBorderColor: { light: highlightBorderColor } }),
...((brandColor ||
highlightBorderColor ||
Object.keys(survey.styling ?? {}).length > 0) && {
overwriteThemeStyling: true,
}),
},
productOverwrites: {
...rest,
},
},
});
}
}
}
}
}
},
{
timeout: 50000,
}
);
};
main()
.catch(async (e) => {
console.error(e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());
@@ -1,58 +0,0 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const main = async () => {
await prisma.$transaction(
async (tx) => {
// get all the persons that have an attribute class with the name "userId"
const userIdAttributeClasses = await tx.attributeClass.findMany({
where: {
name: "userId",
},
include: {
attributes: {
include: { person: true },
},
},
});
for (let attributeClass of userIdAttributeClasses) {
for (let attribute of attributeClass.attributes) {
if (attribute.person.userId) {
continue;
}
await tx.person.update({
where: {
id: attribute.personId,
},
data: {
userId: attribute.value,
},
});
}
}
console.log("Migrated userIds to the person table.");
// Delete all attributeClasses with the name "userId"
await tx.attributeClass.deleteMany({
where: {
name: "userId",
},
});
console.log("Deleted attributeClasses with the name 'userId'.");
},
{
timeout: 60000 * 3, // 3 minutes
}
);
};
main()
.catch(async (e) => {
console.error(e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());
@@ -1,144 +0,0 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const main = async () => {
await prisma.$transaction(
async (tx) => {
// Retrieve all surveys of type "web" with necessary fields for efficient processing
const webSurveys = await tx.survey.findMany({
where: { type: "web" },
select: {
id: true,
segment: {
select: {
id: true,
isPrivate: true,
},
},
},
});
const linkSurveysWithSegment = await tx.survey.findMany({
where: {
type: "link",
segmentId: {
not: null,
},
},
include: {
segment: true,
},
});
const updateOperations = [];
const segmentDeletionIds = [];
const surveyTitlesForDeletion = [];
if (webSurveys?.length > 0) {
for (const webSurvey of webSurveys) {
const latestResponse = await tx.response.findFirst({
where: { surveyId: webSurvey.id },
orderBy: { createdAt: "desc" },
select: { personId: true },
});
const newType = latestResponse?.personId ? "app" : "website";
updateOperations.push(
tx.survey.update({
where: { id: webSurvey.id },
data: { type: newType },
})
);
if (newType === "website") {
if (webSurvey.segment) {
if (webSurvey.segment.isPrivate) {
segmentDeletionIds.push(webSurvey.segment.id);
} else {
updateOperations.push(
tx.survey.update({
where: { id: webSurvey.id },
data: {
segment: { disconnect: true },
},
})
);
}
}
surveyTitlesForDeletion.push(webSurvey.id);
}
}
await Promise.all(updateOperations);
if (segmentDeletionIds.length > 0) {
await tx.segment.deleteMany({
where: {
id: { in: segmentDeletionIds },
},
});
}
if (surveyTitlesForDeletion.length > 0) {
await tx.segment.deleteMany({
where: {
title: { in: surveyTitlesForDeletion },
isPrivate: true,
},
});
}
}
if (linkSurveysWithSegment?.length > 0) {
const linkSurveySegmentDeletionIds = [];
const linkSurveySegmentUpdateOperations = [];
for (const linkSurvey of linkSurveysWithSegment) {
const { segment } = linkSurvey;
if (segment) {
linkSurveySegmentUpdateOperations.push(
tx.survey.update({
where: {
id: linkSurvey.id,
},
data: {
segment: {
disconnect: true,
},
},
})
);
if (segment.isPrivate) {
linkSurveySegmentDeletionIds.push(segment.id);
}
}
}
await Promise.all(linkSurveySegmentUpdateOperations);
if (linkSurveySegmentDeletionIds.length > 0) {
await tx.segment.deleteMany({
where: {
id: { in: linkSurveySegmentDeletionIds },
},
});
}
}
},
{
timeout: 50000,
}
);
};
main()
.catch((e: Error) => {
console.error("Error during migration: ", e.message);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -1,205 +0,0 @@
import { init } from "@paralleldrive/cuid2";
import { Prisma, PrismaClient } from "@prisma/client";
const createId = init({ length: 5 });
const prisma = new PrismaClient();
const main = async () => {
await prisma.$transaction(
async (tx) => {
const startTime = Date.now();
console.log("Starting data migration...");
// 1. copy value of name to key for all action classes where type is code
const codeActionClasses = await tx.actionClass.findMany({
where: {
type: "code",
},
});
console.log(`Found ${codeActionClasses.length} saved code action classes to update.`);
await Promise.all(
codeActionClasses.map((codeActionClass) => {
return tx.actionClass.update({
where: {
id: codeActionClass.id,
},
data: {
key: codeActionClass.name,
},
});
})
);
console.log("Updated keys for saved code action classes.");
// 2. find all surveys with inlineTriggers and create action classes for them
const surveysWithInlineTriggers = await tx.survey.findMany({
where: {
inlineTriggers: {
not: Prisma.JsonNull,
},
},
});
console.log(`Found ${surveysWithInlineTriggers.length} surveys with inline triggers to process.`);
// 3. Create action classes for inlineTriggers and update survey to use the newly created action classes
const getActionClassIdByCode = async (code: string, environmentId: string): Promise<string> => {
const existingActionClass = await tx.actionClass.findFirst({
where: {
type: "code",
key: code,
environmentId: environmentId,
},
});
let codeActionId = "";
if (existingActionClass) {
codeActionId = existingActionClass.id;
} else {
let codeActionClassName = code;
// check if there is an existing noCode action class with this name
const existingNoCodeActionClass = await tx.actionClass.findFirst({
where: {
name: code,
environmentId: environmentId,
NOT: {
type: "code",
},
},
});
if (existingNoCodeActionClass) {
codeActionClassName = `${code}-${createId()}`;
}
// create a new private action for codeConfig
const codeActionClass = await tx.actionClass.create({
data: {
name: codeActionClassName,
key: code,
type: "code",
environment: {
connect: {
id: environmentId,
},
},
},
});
codeActionId = codeActionClass.id;
}
return codeActionId;
};
for (const survey of surveysWithInlineTriggers) {
const { codeConfig, noCodeConfig } = survey.inlineTriggers ?? {};
if (
noCodeConfig &&
Object.keys(noCodeConfig).length > 0 &&
(!codeConfig || codeConfig.identifier === "")
) {
// surveys with only noCodeConfig
// create a new private action for noCodeConfig
const noCodeActionClass = await tx.actionClass.create({
data: {
name: `Custom Action-${createId()}`,
noCodeConfig,
type: "noCode",
environment: {
connect: {
id: survey.environmentId,
},
},
},
});
// update survey to use the newly created action class
await tx.survey.update({
where: {
id: survey.id,
},
data: {
triggers: {
create: {
actionClassId: noCodeActionClass.id,
},
},
},
});
} else if ((!noCodeConfig || Object.keys(noCodeConfig).length === 0) && codeConfig?.identifier) {
const codeActionId = await getActionClassIdByCode(codeConfig.identifier, survey.environmentId);
await tx.survey.update({
where: {
id: survey.id,
},
data: {
triggers: {
create: {
actionClassId: codeActionId,
},
},
},
});
} else if (codeConfig?.identifier && noCodeConfig) {
// create a new private action for noCodeConfig
const noCodeActionClass = await tx.actionClass.create({
data: {
name: `Custom Action-${createId()}`,
noCodeConfig,
type: "noCode",
environment: {
connect: {
id: survey.environmentId,
},
},
},
});
const codeActionId = await getActionClassIdByCode(codeConfig.identifier, survey.environmentId);
// update survey to use the newly created action classes
await tx.survey.update({
where: {
id: survey.id,
},
data: {
triggers: {
createMany: {
data: [
{
actionClassId: noCodeActionClass.id,
},
{
actionClassId: codeActionId,
},
],
},
},
},
});
}
}
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${(endTime - startTime) / 1000}s`);
},
{
timeout: 180000, // 3 minutes
}
);
};
main()
.catch((e: Error) => {
console.error("Error during migration: ", e.message);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -1,123 +0,0 @@
import { PrismaClient } from "@prisma/client";
import { TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
const prisma = new PrismaClient();
const main = async () => {
await prisma.$transaction(
async (tx) => {
const startTime = Date.now();
console.log("Starting data migration...");
const getConfig = (noCodeConfig: any): TActionClassNoCodeConfig => {
const cssSelector = noCodeConfig?.cssSelector?.value;
const innerHtml = noCodeConfig?.innerHtml?.value;
const pageUrl = noCodeConfig?.pageUrl;
const urlFilters = pageUrl ? [pageUrl] : [];
if (!cssSelector && !innerHtml && pageUrl) {
return {
type: "pageView",
urlFilters,
};
} else {
return {
type: "click",
elementSelector: {
...(cssSelector && { cssSelector }),
...(innerHtml && { innerHtml }),
},
urlFilters,
};
}
};
// 1. Updation of all noCode actions to fit in the latest schema
const noCodeActionClasses = await tx.actionClass.findMany({
where: {
type: "noCode",
},
select: {
id: true,
noCodeConfig: true,
},
});
console.log(`Found ${noCodeActionClasses.length} noCode action classes to update.`);
await Promise.all(
noCodeActionClasses.map((noCodeActionClass) => {
return tx.actionClass.update({
where: {
id: noCodeActionClass.id,
},
data: {
noCodeConfig: getConfig(noCodeActionClass.noCodeConfig),
},
});
})
);
const targetAutomaticActions = [
{ name: "Exit Intent (Desktop)", type: "exitIntent" },
{ name: "50% Scroll", type: "fiftyPercentScroll" },
];
// 2. Update "Exit Intent (Desktop)", "50% Scroll" automatic actions classes that have atleast one survey trigger to noCode actions, update them one by one
const automaticActionClassesToUpdatePromises = targetAutomaticActions.map((action) => {
return tx.actionClass.updateMany({
where: {
name: action.name,
type: "automatic",
surveys: {
some: {},
},
},
data: {
type: "noCode",
noCodeConfig: {
type: action.type as "exitIntent" | "fiftyPercentScroll",
urlFilters: [],
},
},
});
});
const targetAutomaticActionNames = targetAutomaticActions.map((action) => action.name);
console.log(`Updating ${targetAutomaticActionNames.join(" and ")} action classes...`);
await Promise.all(automaticActionClassesToUpdatePromises);
// 3. Delete all automatic action classes that are not associated with a survey
const automaticActionClassesToDelete = await tx.actionClass.deleteMany({
where: {
name: {
in: targetAutomaticActionNames,
},
type: "automatic",
},
});
console.log(
`Deleted ${automaticActionClassesToDelete.count} unused automatic action classes of 50% scroll and exit intent.`
);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${(endTime - startTime) / 1000}s`);
},
{
timeout: 180000, // 3 minutes
}
);
};
main()
.catch((e: Error) => {
console.error("Error during migration: ", e.message);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -1,91 +0,0 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const main = async () => {
await prisma.$transaction(
async (tx) => {
const startTime = Date.now();
console.log("Starting data migration...");
// Retrieve all environments with widget setup completed
const environmentsWithWidgetSetupCompleted = await tx.environment.findMany({
where: {
widgetSetupCompleted: true,
},
select: {
id: true,
},
});
console.log(
`Found ${environmentsWithWidgetSetupCompleted.length} environments with widget setup completed.`
);
if (environmentsWithWidgetSetupCompleted.length > 0) {
const environmentIds = environmentsWithWidgetSetupCompleted.map((env) => env.id);
// Fetch survey counts in a single query
const surveyCounts = await tx.survey.groupBy({
by: ["environmentId", "type"],
where: {
environmentId: {
in: environmentIds,
},
displays: {
some: {},
},
type: {
in: ["app", "website"],
},
},
_count: {
_all: true,
},
});
// Create a map of environmentId to survey counts
const surveyCountMap = surveyCounts.reduce(
(acc, survey) => {
if (!acc[survey.environmentId]) {
acc[survey.environmentId] = { website: 0, app: 0, link: 0, web: 0 };
}
acc[survey.environmentId][survey.type] = survey._count._all;
return acc;
},
{} as Record<string, { website: number; app: number; link: number; web: number }>
);
// Update the appSetupCompleted and websiteSetupCompleted flags for each environment
const updatePromises = environmentsWithWidgetSetupCompleted.map((environment) => {
const counts = surveyCountMap[environment.id] || { website: 0, app: 0 };
return tx.environment.update({
where: { id: environment.id },
data: {
appSetupCompleted: counts.app > 0,
websiteSetupCompleted: counts.website > 0,
},
});
});
await Promise.all(updatePromises);
}
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${(endTime - startTime) / 1000}s`);
},
{
timeout: 50000,
}
);
};
main()
.catch((e: Error) => {
console.error("Error during migration: ", e.message);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -1,109 +0,0 @@
import { PrismaClient, SurveyType } from "@prisma/client";
import { TProjectConfigChannel } from "@formbricks/types/project";
const prisma = new PrismaClient();
const main = async () => {
await prisma.$transaction(
async (tx) => {
const startTime = Date.now();
console.log("Starting data migration...");
// Fetch all products
const products = await tx.product.findMany({
include: {
environments: {
select: {
surveys: {
select: {
type: true,
},
},
},
},
},
});
console.log(`Found ${products.length} products to migrate...\n`);
const channelStatusCounts = {
[SurveyType.app]: 0,
[SurveyType.link]: 0,
[SurveyType.website]: 0,
null: 0,
};
const updatePromises = products.map((product) => {
const surveyTypes = new Set<SurveyType>();
// Collect all unique survey types for the product
for (const environment of product.environments) {
for (const survey of environment.surveys) {
surveyTypes.add(survey.type);
}
}
// Determine the channel based on the survey types, default to null
let channel: TProjectConfigChannel = null;
if (surveyTypes.size === 0 || surveyTypes.size === 3) {
// if there are no surveys or all 3 types of surveys (website, app, and link) are present, set channel to null
channel = null;
} else if (surveyTypes.size === 1) {
// if there is only one type of survey, set channel to that type
const type = Array.from(surveyTypes)[0];
if (type === SurveyType.web) {
// if the survey type is web, set channel to null, since web is a legacy type and will be removed
channel = null;
} else {
// if the survey type is not web, set channel to that type
channel = type;
}
} else if (surveyTypes.has(SurveyType.link) && surveyTypes.has(SurveyType.app)) {
// if both link and app surveys are present, set channel to app
channel = SurveyType.app;
} else if (surveyTypes.has(SurveyType.link) && surveyTypes.has(SurveyType.website)) {
// if both link and website surveys are present, set channel to website
channel = SurveyType.website;
}
// Increment the count for the determined channel
channelStatusCounts[channel ?? "null"]++;
// Update the product with the determined channel and set industry to null
return tx.product.update({
where: { id: product.id },
data: {
config: {
channel,
industry: null,
},
},
});
});
await Promise.all(updatePromises);
console.log(
`Channel status counts: ${Object.entries(channelStatusCounts).map(
([channel, count]) => `\n${channel}: ${count}`
)}\n`
);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${(endTime - startTime) / 1000}s`);
},
{
timeout: 180000, // 3 minutes
}
);
};
main()
.catch((e: Error) => {
console.error("Error during migration: ", e.message);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -1,183 +0,0 @@
import { Prisma, PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
type TSubscriptionStatusLegacy = "active" | "cancelled" | "inactive";
interface TSubscriptionLegacy {
status: TSubscriptionStatusLegacy;
unlimited: boolean;
}
interface TFeatures {
inAppSurvey: TSubscriptionLegacy;
linkSurvey: TSubscriptionLegacy;
userTargeting: TSubscriptionLegacy;
multiLanguage: TSubscriptionLegacy;
}
interface TOrganizationBillingLegacy {
stripeCustomerId: string | null;
features: TFeatures;
}
const now = new Date();
const firstOfMonthUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
async function main() {
await prisma.$transaction(
async (tx) => {
// const startTime = Date.now();
console.log("Starting data migration for pricing v2...");
// Free tier
const orgsWithoutBilling = await tx.organization.findMany({
where: {
billing: {
path: ["stripeCustomerId"],
equals: Prisma.AnyNull,
},
},
});
console.log(
`Found ${orgsWithoutBilling.length} organizations without billing information. Moving them to free plan...`
);
const freePlanPromises = orgsWithoutBilling
// if the organization has a plan, it means it's already migrated
.filter((org) => !(org.billing.plan && typeof org.billing.plan === "string"))
.map((organization) =>
tx.organization.update({
where: {
id: organization.id,
},
data: {
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
monthly: {
responses: 500,
miu: 1000,
},
},
periodStart: new Date(),
},
},
})
);
await Promise.all(freePlanPromises);
console.log("Moved all organizations without billing to free plan");
const orgsWithBilling = await tx.organization.findMany({
where: {
billing: {
path: ["stripeCustomerId"],
not: Prisma.AnyNull,
},
},
});
console.log(`Found ${orgsWithBilling.length} organizations with billing information`);
for (const org of orgsWithBilling) {
const billing = org.billing as TOrganizationBillingLegacy;
console.log("Current organization: ", org.id);
// @ts-expect-error
if (billing.plan && typeof billing.plan === "string") {
// no migration needed, already following the latest schema
continue;
}
if (
(billing.features.linkSurvey?.status === "active" && billing.features.linkSurvey?.unlimited) ||
(billing.features.inAppSurvey?.status === "active" && billing.features.inAppSurvey?.unlimited) ||
(billing.features.userTargeting?.status === "active" && billing.features.userTargeting?.unlimited)
) {
await tx.organization.update({
where: {
id: org.id,
},
data: {
billing: {
plan: "enterprise",
period: "monthly",
limits: {
monthly: {
responses: null,
miu: null,
},
},
stripeCustomerId: billing.stripeCustomerId,
periodStart: firstOfMonthUTC,
},
},
});
console.log("Updated org with unlimited to enterprise plan: ", org.id);
continue;
}
if (billing.features.linkSurvey.status === "active") {
await tx.organization.update({
where: {
id: org.id,
},
data: {
billing: {
plan: "startup",
period: "monthly",
limits: {
monthly: {
responses: 2000,
miu: 2500,
},
},
stripeCustomerId: billing.stripeCustomerId,
periodStart: firstOfMonthUTC,
},
},
});
console.log("Updated org with linkSurvey to pro plan: ", org.id);
continue;
}
await tx.organization.update({
where: {
id: org.id,
},
data: {
billing: {
plan: "free",
period: "monthly",
limits: {
monthly: {
responses: 500,
miu: 1000,
},
},
stripeCustomerId: billing.stripeCustomerId,
periodStart: new Date(),
},
},
});
console.log("Updated org to free plan: ", org.id);
}
},
{ timeout: 50000 }
);
}
main()
.catch(async (e) => {
console.error(e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());
@@ -1,119 +0,0 @@
// migration script for converting zh code for chinese language to zh-Hans
import { PrismaClient } from "@prisma/client";
import {
TSurveyLanguage,
TSurveyQuestion,
TSurveyThankYouCard,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys";
import {
updateLanguageCodeForQuestion,
updateLanguageCodeForThankYouCard,
updateLanguageCodeForWelcomeCard,
} from "./lib/utils";
const prisma = new PrismaClient();
const main = async () => {
await prisma.$transaction(
async (tx) => {
const startTime = Date.now();
console.log("Starting data migration...");
// Fetch all surveys
const surveys: {
id: string;
questions: TSurveyQuestion[];
welcomeCard: TSurveyWelcomeCard;
thankYouCard: TSurveyThankYouCard;
languages: TSurveyLanguage[];
}[] = await tx.survey.findMany({
select: {
id: true,
questions: true,
welcomeCard: true,
thankYouCard: true,
languages: {
select: {
default: true,
enabled: true,
language: {
select: {
id: true,
code: true,
alias: true,
createdAt: true,
updatedAt: true,
},
},
},
},
},
});
if (surveys.length === 0) {
// stop the migration if there are no surveys
console.log("No Surveys found");
return;
}
console.log(`Total surveys found:${surveys.length}`);
let transformedSurveyCount = 0;
const surveysWithChineseTranslations = surveys.filter((survey) =>
survey.languages.some((surveyLanguage) => surveyLanguage.language.code === "zh")
);
const updatePromises = surveysWithChineseTranslations.map((survey) => {
transformedSurveyCount++;
const updatedSurvey = structuredClone(survey);
// Update cards and questions
updatedSurvey.welcomeCard = updateLanguageCodeForWelcomeCard(survey.welcomeCard, "zh", "zh-Hans");
updatedSurvey.thankYouCard = updateLanguageCodeForThankYouCard(survey.thankYouCard, "zh", "zh-Hans");
updatedSurvey.questions = survey.questions.map((question) =>
updateLanguageCodeForQuestion(question, "zh", "zh-Hans")
);
// Return the update promise
return tx.survey.update({
where: { id: survey.id },
data: {
welcomeCard: updatedSurvey.welcomeCard,
thankYouCard: updatedSurvey.thankYouCard,
questions: updatedSurvey.questions,
},
});
});
await Promise.all(updatePromises);
console.log(transformedSurveyCount, " surveys transformed");
console.log("updating languages");
await tx.language.updateMany({
where: {
code: "zh",
},
data: {
code: "zh-Hans",
},
});
console.log("survey language updated");
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${(endTime - startTime) / 1000}s`);
},
{
timeout: 180000, // 3 minutes
}
);
};
main()
.catch((e: Error) => {
console.error("Error during migration: ", e.message);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -1,259 +0,0 @@
import {
TI18nString,
TSurveyCTAQuestion,
TSurveyChoice,
TSurveyConsentQuestion,
TSurveyMatrixQuestion,
TSurveyMultipleChoiceQuestion,
TSurveyNPSQuestion,
TSurveyOpenTextQuestion,
TSurveyQuestion,
TSurveyRatingQuestion,
TSurveyThankYouCard,
TSurveyWelcomeCard,
ZSurveyAddressQuestion,
ZSurveyCTAQuestion,
ZSurveyCalQuestion,
ZSurveyConsentQuestion,
ZSurveyFileUploadQuestion,
ZSurveyMatrixQuestion,
ZSurveyMultipleChoiceQuestion,
ZSurveyNPSQuestion,
ZSurveyOpenTextQuestion,
ZSurveyPictureSelectionQuestion,
ZSurveyQuestion,
ZSurveyRatingQuestion,
ZSurveyThankYouCard,
ZSurveyWelcomeCard,
} from "@formbricks/types/surveys";
export const updateLanguageCode = (i18nString: TI18nString, oldCode: string, newCode: string) => {
const updatedI18nString = structuredClone(i18nString);
if (Object.keys(i18nString).includes(oldCode)) {
const text = i18nString[oldCode];
delete updatedI18nString[oldCode];
updatedI18nString[newCode] = text;
}
return updatedI18nString;
};
export const updateChoiceLanguage = (
choice: TSurveyChoice,
oldCode: string,
newCode: string
): TSurveyChoice => {
if (typeof choice.label !== "undefined" && Object.keys(choice.label).includes(oldCode)) {
return {
...choice,
label: updateLanguageCode(choice.label, oldCode, newCode),
};
} else {
return {
...choice,
label: choice.label,
};
}
};
export const updateLanguageCodeForWelcomeCard = (
welcomeCard: TSurveyWelcomeCard,
oldCode: string,
newCode: string
) => {
const clonedWelcomeCard = structuredClone(welcomeCard);
if (typeof welcomeCard.headline !== "undefined" && Object.keys(welcomeCard.headline).includes(oldCode)) {
clonedWelcomeCard.headline = updateLanguageCode(welcomeCard.headline, oldCode, newCode);
}
if (typeof welcomeCard.html !== "undefined" && Object.keys(welcomeCard.html).includes(oldCode)) {
clonedWelcomeCard.html = updateLanguageCode(welcomeCard.html, oldCode, newCode);
}
if (
typeof welcomeCard.buttonLabel !== "undefined" &&
Object.keys(welcomeCard.buttonLabel).includes(oldCode)
) {
clonedWelcomeCard.buttonLabel = updateLanguageCode(welcomeCard.buttonLabel, oldCode, newCode);
}
return ZSurveyWelcomeCard.parse(clonedWelcomeCard);
};
export const updateLanguageCodeForThankYouCard = (
thankYouCard: TSurveyThankYouCard,
oldCode: string,
newCode: string
) => {
const clonedThankYouCard = structuredClone(thankYouCard);
if (typeof thankYouCard.headline !== "undefined" && Object.keys(thankYouCard.headline).includes(oldCode)) {
clonedThankYouCard.headline = updateLanguageCode(thankYouCard.headline, oldCode, newCode);
}
if (
typeof thankYouCard.subheader !== "undefined" &&
Object.keys(thankYouCard.subheader).includes(oldCode)
) {
clonedThankYouCard.subheader = updateLanguageCode(thankYouCard.subheader, oldCode, newCode);
}
if (
typeof thankYouCard.buttonLabel !== "undefined" &&
Object.keys(thankYouCard.buttonLabel).includes(oldCode)
) {
clonedThankYouCard.buttonLabel = updateLanguageCode(thankYouCard.buttonLabel, oldCode, newCode);
}
return ZSurveyThankYouCard.parse(clonedThankYouCard);
};
export const updateLanguageCodeForQuestion = (
question: TSurveyQuestion,
oldCode: string,
newCode: string
) => {
const clonedQuestion = structuredClone(question);
//common question properties
if (typeof question.headline !== "undefined" && Object.keys(question.headline).includes(oldCode)) {
clonedQuestion.headline = updateLanguageCode(question.headline, oldCode, newCode);
}
if (typeof question.subheader !== "undefined" && Object.keys(question.subheader).includes(oldCode)) {
clonedQuestion.subheader = updateLanguageCode(question.subheader, oldCode, newCode);
}
if (typeof question.buttonLabel !== "undefined" && Object.keys(question.buttonLabel).includes(oldCode)) {
clonedQuestion.buttonLabel = updateLanguageCode(question.buttonLabel, oldCode, newCode);
}
if (
typeof question.backButtonLabel !== "undefined" &&
Object.keys(question.backButtonLabel).includes(oldCode)
) {
clonedQuestion.backButtonLabel = updateLanguageCode(question.backButtonLabel, oldCode, newCode);
}
switch (question.type) {
case "openText":
if (
typeof question.placeholder !== "undefined" &&
Object.keys(question.placeholder).includes(oldCode)
) {
(clonedQuestion as TSurveyOpenTextQuestion).placeholder = updateLanguageCode(
question.placeholder,
oldCode,
newCode
);
}
return ZSurveyOpenTextQuestion.parse(clonedQuestion);
case "multipleChoiceSingle":
case "multipleChoiceMulti":
(clonedQuestion as TSurveyMultipleChoiceQuestion).choices = question.choices.map((choice) => {
return updateChoiceLanguage(choice, oldCode, newCode);
});
if (
typeof question.otherOptionPlaceholder !== "undefined" &&
Object.keys(question.otherOptionPlaceholder).includes(oldCode)
) {
(clonedQuestion as TSurveyMultipleChoiceQuestion).otherOptionPlaceholder = updateLanguageCode(
question.otherOptionPlaceholder,
oldCode,
newCode
);
}
return ZSurveyMultipleChoiceQuestion.parse(clonedQuestion);
case "cta":
if (
typeof question.dismissButtonLabel !== "undefined" &&
Object.keys(question.dismissButtonLabel).includes(oldCode)
) {
(clonedQuestion as TSurveyCTAQuestion).dismissButtonLabel = updateLanguageCode(
question.dismissButtonLabel,
oldCode,
newCode
);
}
if (typeof question.html !== "undefined" && Object.keys(question.html).includes(oldCode)) {
(clonedQuestion as TSurveyCTAQuestion).html = updateLanguageCode(question.html, oldCode, newCode);
}
return ZSurveyCTAQuestion.parse(clonedQuestion);
case "consent":
if (typeof question.html !== "undefined" && Object.keys(question.html).includes(oldCode)) {
(clonedQuestion as TSurveyConsentQuestion).html = updateLanguageCode(question.html, oldCode, newCode);
}
if (typeof question.label !== "undefined" && Object.keys(question.label).includes(oldCode)) {
(clonedQuestion as TSurveyConsentQuestion).label = updateLanguageCode(
question.label,
oldCode,
newCode
);
}
return ZSurveyConsentQuestion.parse(clonedQuestion);
case "nps":
if (typeof question.lowerLabel !== "undefined" && Object.keys(question.lowerLabel).includes(oldCode)) {
(clonedQuestion as TSurveyNPSQuestion).lowerLabel = updateLanguageCode(
question.lowerLabel,
oldCode,
newCode
);
}
if (typeof question.upperLabel !== "undefined" && Object.keys(question.upperLabel).includes(oldCode)) {
(clonedQuestion as TSurveyNPSQuestion).upperLabel = updateLanguageCode(
question.upperLabel,
oldCode,
newCode
);
}
return ZSurveyNPSQuestion.parse(clonedQuestion);
case "rating":
if (typeof question.lowerLabel !== "undefined" && Object.keys(question.lowerLabel).includes(oldCode)) {
(clonedQuestion as TSurveyRatingQuestion).lowerLabel = updateLanguageCode(
question.lowerLabel,
oldCode,
newCode
);
}
if (typeof question.upperLabel !== "undefined" && Object.keys(question.upperLabel).includes(oldCode)) {
(clonedQuestion as TSurveyRatingQuestion).upperLabel = updateLanguageCode(
question.upperLabel,
oldCode,
newCode
);
}
return ZSurveyRatingQuestion.parse(clonedQuestion);
case "matrix":
(clonedQuestion as TSurveyMatrixQuestion).rows = question.rows.map((row) => {
if (typeof row !== "undefined" && Object.keys(row).includes(oldCode)) {
return updateLanguageCode(row, oldCode, newCode);
} else return row;
});
(clonedQuestion as TSurveyMatrixQuestion).columns = question.columns.map((column) => {
if (typeof column !== "undefined" && Object.keys(column).includes(oldCode)) {
return updateLanguageCode(column, oldCode, newCode);
} else return column;
});
return ZSurveyMatrixQuestion.parse(clonedQuestion);
case "fileUpload":
return ZSurveyFileUploadQuestion.parse(clonedQuestion);
case "pictureSelection":
return ZSurveyPictureSelectionQuestion.parse(clonedQuestion);
case "cal":
return ZSurveyCalQuestion.parse(clonedQuestion);
case "address":
return ZSurveyAddressQuestion.parse(clonedQuestion);
default:
return ZSurveyQuestion.parse(clonedQuestion);
}
};
@@ -1,75 +0,0 @@
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function runMigration(): Promise<void> {
await prisma.$transaction(
async (tx) => {
console.log("starting migration");
const segmentsWithNoSurveys = await tx.segment.findMany({
where: {
surveys: {
none: {},
},
},
});
const surveyIds = segmentsWithNoSurveys.map((segment) => segment.id);
await tx.segment.deleteMany({
where: {
id: {
in: surveyIds,
},
},
});
console.log(`Deleted ${segmentsWithNoSurveys.length.toString()} segments with no surveys`);
const appSurveysWithoutSegment = await tx.survey.findMany({
where: {
type: "app",
segmentId: null,
},
});
console.log(`Found ${appSurveysWithoutSegment.length.toString()} app surveys without a segment`);
const segmentPromises = appSurveysWithoutSegment.map((appSurvey) =>
tx.segment.create({
data: {
title: appSurvey.id,
isPrivate: true,
environment: { connect: { id: appSurvey.environmentId } },
surveys: { connect: { id: appSurvey.id } },
},
})
);
await Promise.all(segmentPromises);
console.log("Migration completed");
},
{ timeout: 50000 }
);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,73 +0,0 @@
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function runMigration(): Promise<void> {
await prisma.$transaction(
async (tx) => {
const startTime = Date.now();
console.log("Starting data migration...");
// Fetch all surveys
const surveys = await tx.survey.findMany({
select: {
id: true,
verifyEmail: true,
},
});
if (surveys.length === 0) {
// stop the migration if there are no surveys
console.log("No Surveys found");
return;
}
let transformedSurveyCount = 0;
const surveysWithEmailVerificationEnabled = surveys.filter(
(survey) => survey.verifyEmail !== null && survey.verifyEmail !== undefined
);
const updatePromises = surveysWithEmailVerificationEnabled.map((survey) => {
transformedSurveyCount++;
// Return the update promise
return tx.survey.update({
where: { id: survey.id },
data: {
isVerifyEmailEnabled: true,
verifyEmail: null,
},
});
});
await Promise.all(updatePromises);
console.log(transformedSurveyCount, " surveys transformed");
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toString()}s`);
},
{
timeout: 180000, // 3 minutes
}
);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,113 +0,0 @@
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { createId } from "@paralleldrive/cuid2";
import { PrismaClient } from "@prisma/client";
import { type TSurveyEndings } from "@formbricks/types/surveys/types";
interface Survey {
id: string;
thankYouCard: {
enabled: boolean;
title: string;
description: string;
} | null;
redirectUrl: string | null;
}
interface UpdatedSurvey extends Survey {
endings?: TSurveyEndings;
}
const prisma = new PrismaClient();
async function runMigration(): Promise<void> {
await prisma.$transaction(
async (tx) => {
const startTime = Date.now();
console.log("Starting data migration...");
// Fetch all surveys
const surveys: Survey[] = (await tx.survey.findMany({
select: {
id: true,
thankYouCard: true,
redirectUrl: true,
},
})) as Survey[];
if (surveys.length === 0) {
// Stop the migration if there are no surveys
console.log("No Surveys found");
return;
}
console.log(`Total surveys found: ${surveys.length.toString()}`);
let transformedSurveyCount = 0;
const updatePromises = surveys
.filter((s) => s.thankYouCard !== null)
.map((survey) => {
transformedSurveyCount++;
const updatedSurvey: UpdatedSurvey = structuredClone(survey) as UpdatedSurvey;
if (survey.redirectUrl) {
updatedSurvey.endings = [
{
type: "redirectToUrl",
label: "Redirect Url",
id: createId(),
url: survey.redirectUrl,
},
];
} else if (survey.thankYouCard?.enabled) {
updatedSurvey.endings = [
{
...survey.thankYouCard,
type: "endScreen",
id: createId(),
},
];
} else {
updatedSurvey.endings = [];
}
// Return the update promise
return tx.survey.update({
where: { id: survey.id },
data: {
endings: updatedSurvey.endings,
thankYouCard: null,
redirectUrl: null,
},
});
});
await Promise.all(updatePromises);
console.log(`${transformedSurveyCount.toString()} surveys transformed`);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toString()}s`);
},
{
timeout: 180000, // 3 minutes
}
);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,111 +0,0 @@
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
import { type TSurveyEnding, type TSurveyQuestion } from "@formbricks/types/surveys/types";
const prisma = new PrismaClient();
async function runMigration(): Promise<void> {
await prisma.$transaction(
async (tx) => {
const startTime = Date.now();
console.log("Starting data migration...");
// Fetch all surveys
const surveys: { id: string; questions: TSurveyQuestion[]; endings: TSurveyEnding[] }[] =
await tx.survey.findMany({
select: {
id: true,
questions: true,
endings: true,
},
});
if (surveys.length === 0) {
// Stop the migration if there are no surveys
console.log("No Surveys found");
return;
}
// Get all surveys that have a logic rule that has "end" as the destination
const surveysWithEndDestination = surveys.filter((survey) =>
survey.questions.some((question) => question.logic?.some((rule) => rule.destination === "end"))
);
console.log(`Total surveys to update found: ${surveysWithEndDestination.length.toString()}`);
let transformedSurveyCount = 0;
const updatePromises = surveysWithEndDestination.map((survey) => {
const updatedSurvey = structuredClone(survey);
// Remove logic rule if there are no endings
if (updatedSurvey.endings.length === 0) {
// remove logic rule if there are no endings
updatedSurvey.questions.forEach((question) => {
if (typeof question.logic === "undefined") {
return;
}
question.logic.forEach((rule, index) => {
if (rule.destination === "end") {
if (question.logic) question.logic.splice(index, 1);
}
});
});
}
// get id of first ending
const firstEnding = survey.endings[0];
// replace logic destination with ending id
updatedSurvey.questions.forEach((question) => {
if (typeof question.logic === "undefined") {
return;
}
question.logic.forEach((rule) => {
if (rule.destination === "end") {
rule.destination = firstEnding.id;
}
});
});
transformedSurveyCount++;
// Return the update promise
return tx.survey.update({
where: { id: survey.id },
data: {
questions: updatedSurvey.questions,
},
});
});
await Promise.all(updatePromises);
console.log(`${transformedSurveyCount.toString()} surveys transformed`);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toString()}s`);
},
{
timeout: 180000, // 3 minutes
}
);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,107 +0,0 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions -- using template strings for logging */
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
import {
type TSurveyQuestion,
type TSurveyQuestionId,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
const prisma = new PrismaClient();
async function runMigration(): Promise<void> {
await prisma.$transaction(
async (tx) => {
const startTime = Date.now();
console.log("Starting data migration...");
// Get all surveys with status not in draft and questions containing cta or consent
const relevantSurveys = await tx.survey.findMany({
where: {
status: {
notIn: ["draft"],
},
OR: [
{
questions: {
array_contains: [{ type: "cta" }],
},
},
{
questions: {
array_contains: [{ type: "consent" }],
},
},
],
},
select: {
id: true,
questions: true,
},
});
// Process each survey
const migrationPromises = relevantSurveys.map(async (survey) => {
const ctaOrConsentQuestionIds = survey.questions
.filter(
(ques: TSurveyQuestion) =>
ques.type === TSurveyQuestionTypeEnum.CTA || ques.type === TSurveyQuestionTypeEnum.Consent
)
.map((ques: TSurveyQuestion) => ques.id);
const responses = await tx.response.findMany({
where: { surveyId: survey.id },
select: { id: true, data: true },
});
return Promise.all(
responses.map(async (response) => {
const updatedData = { ...response.data };
ctaOrConsentQuestionIds.forEach((questionId: TSurveyQuestionId) => {
if (updatedData[questionId] && updatedData[questionId] === "dismissed") {
updatedData[questionId] = "";
}
});
return tx.response.update({
where: { id: response.id },
data: { data: updatedData },
});
})
);
});
await Promise.all(migrationPromises);
console.log(`Updated ${migrationPromises.length} questions in ${relevantSurveys.length} surveys`);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toString()}s`);
},
{
timeout: 900000, // 15 minutes
}
);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,316 +0,0 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions -- string interpolation is allowed in migration scripts */
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { createId } from "@paralleldrive/cuid2";
import { PrismaClient } from "@prisma/client";
import {
type TRightOperand,
type TSingleCondition,
type TSurveyEndings,
type TSurveyLogic,
type TSurveyLogicAction,
type TSurveyLogicConditionsOperator,
type TSurveyMultipleChoiceQuestion,
type TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
const prisma = new PrismaClient();
interface TOldLogic {
condition: string;
value?: string | string[];
destination: string;
}
const isOldLogic = (logic: TOldLogic | TSurveyLogic): logic is TOldLogic => {
return Object.keys(logic).some((key) => ["condition", "destination", "value"].includes(key));
};
const doesRightOperandExist = (operator: TSurveyLogicConditionsOperator): boolean => {
return ![
"isAccepted",
"isBooked",
"isClicked",
"isCompletelySubmitted",
"isPartiallySubmitted",
"isSkipped",
"isSubmitted",
].includes(operator);
};
const getChoiceId = (question: TSurveyMultipleChoiceQuestion, choiceText: string): string | undefined => {
const choiceOption = question.choices.find((choice) => choice.label.default === choiceText);
if (choiceOption) {
return choiceOption.id;
}
if (question.choices.at(-1)?.id === "other") {
return "other";
}
};
const getRightOperandValue = (
oldCondition: string,
oldValue: string | string[] | undefined,
question: TSurveyQuestion
): TRightOperand | undefined => {
if (["lessThan", "lessEqual", "greaterThan", "greaterEqual"].includes(oldCondition)) {
return {
type: "static",
value: parseInt(oldValue as string),
};
}
if (["equals", "notEquals"].includes(oldCondition)) {
if (["string", "number"].includes(typeof oldValue)) {
if (question.type === TSurveyQuestionTypeEnum.Rating || question.type === TSurveyQuestionTypeEnum.NPS) {
return {
type: "static",
value: parseInt(oldValue as string),
};
} else if (
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
) {
const choiceId = getChoiceId(question, oldValue as string);
if (choiceId) {
return {
type: "static",
value: choiceId,
};
}
return undefined;
} else if (question.type === TSurveyQuestionTypeEnum.PictureSelection) {
return {
type: "static",
value: oldValue as string,
};
}
}
return undefined;
}
if (["includesAll", "includesOne"].includes(oldCondition)) {
let choiceIds: string[] = [];
if (oldValue && Array.isArray(oldValue)) {
if (
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle
) {
oldValue.forEach((choiceText) => {
const choiceId = getChoiceId(question, choiceText);
if (choiceId) {
choiceIds.push(choiceId);
}
});
choiceIds = Array.from(new Set(choiceIds));
return {
type: "static",
value: choiceIds,
};
}
return {
type: "static",
value: oldValue,
};
}
return undefined;
}
return undefined;
};
// Helper function to convert old logic condition to new format
function convertLogicCondition(
oldCondition: string,
oldValue: string | string[] | undefined,
question: TSurveyQuestion
): TSingleCondition | undefined {
const operator = mapOldOperatorToNew(oldCondition, question.type);
let rightOperandValue: TRightOperand | undefined;
const doesRightOperandExistResult = doesRightOperandExist(operator);
if (doesRightOperandExistResult) {
rightOperandValue = getRightOperandValue(oldCondition, oldValue, question);
if (!rightOperandValue) {
return undefined;
}
}
const newCondition: TSingleCondition = {
id: createId(),
leftOperand: {
type: "question",
value: question.id,
},
operator,
...(doesRightOperandExistResult ? { rightOperand: rightOperandValue } : {}),
};
return newCondition;
}
// Helper function to map old conditions to new ones
function mapOldOperatorToNew(
oldCondition: string,
questionType: TSurveyQuestionTypeEnum
): TSurveyLogicConditionsOperator {
const conditionMap: Record<string, TSurveyLogicConditionsOperator> = {
accepted: "isAccepted",
clicked: "isClicked",
submitted: "isSubmitted",
skipped: "isSkipped",
equals: "equals",
notEquals: "doesNotEqual",
lessThan: "isLessThan",
lessEqual: "isLessThanOrEqual",
greaterThan: "isGreaterThan",
greaterEqual: "isGreaterThanOrEqual",
includesAll: "includesAllOf",
includesOne: "includesOneOf",
uploaded: "isSubmitted", // Assuming 'uploaded' maps to 'isSubmitted'
notUploaded: "isSkipped", // Assuming 'notUploaded' maps to 'isSkipped'
booked: "isBooked",
isCompletelySubmitted: "isCompletelySubmitted",
isPartiallySubmitted: "isPartiallySubmitted",
};
const newOpeator = conditionMap[oldCondition];
if (questionType === TSurveyQuestionTypeEnum.MultipleChoiceSingle && newOpeator === "includesOneOf") {
return "equalsOneOf";
}
return newOpeator;
}
// Helper function to convert old logic to new format
function convertLogic(
surveyEndings: TSurveyEndings,
oldLogic: TOldLogic,
question: TSurveyQuestion
): TSurveyLogic | undefined {
if (!oldLogic.condition || !oldLogic.destination) {
return undefined;
}
const condition = convertLogicCondition(oldLogic.condition, oldLogic.value, question);
if (!condition) {
return undefined;
}
let actionTarget = oldLogic.destination;
if (actionTarget === "end") {
if (surveyEndings.length > 0) {
actionTarget = surveyEndings[0].id;
} else {
return undefined;
}
}
const action: TSurveyLogicAction = {
id: createId(),
objective: "jumpToQuestion",
target: actionTarget,
};
return {
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [condition],
},
actions: [action],
};
}
async function runMigration(): Promise<void> {
await prisma.$transaction(
async (tx) => {
const startTime = Date.now();
console.log("Starting survey logic migration...");
// Get all surveys with questions containing old logic
const relevantSurveys = await tx.survey.findMany({
select: {
id: true,
questions: true,
endings: true,
},
});
// Process each survey
const migrationPromises = relevantSurveys
.map((survey) => {
let doesThisSurveyHasOldLogic = false;
const questions: TSurveyQuestion[] = [];
for (const question of survey.questions) {
if (question.logic && Array.isArray(question.logic) && question.logic.some(isOldLogic)) {
doesThisSurveyHasOldLogic = true;
const newLogic = (question.logic as unknown as TOldLogic[])
.map((oldLogic) => convertLogic(survey.endings, oldLogic, question))
.filter((logic) => logic !== undefined);
questions.push({ ...question, logic: newLogic });
} else {
questions.push(question);
}
}
if (!doesThisSurveyHasOldLogic) {
return null;
}
return tx.survey.update({
where: { id: survey.id },
data: { questions },
});
})
.filter((promise) => promise !== null);
console.log(`Found ${migrationPromises.length} surveys with old logic`);
await Promise.all(migrationPromises);
const endTime = Date.now();
console.log(
`Survey logic migration completed. Total time: ${((endTime - startTime) / 1000).toString()}s`
);
},
{
timeout: 300000, // 5 minutes
}
);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,88 +0,0 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions -- using template strings for logging */
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
import type { TBaseFilter, TBaseFilters } from "@formbricks/types/segment";
const prisma = new PrismaClient();
function removeActionFilters(filters: TBaseFilters): TBaseFilters {
const cleanedFilters = filters.reduce((acc: TBaseFilters, filter: TBaseFilter) => {
if (Array.isArray(filter.resource)) {
// If it's a group, recursively clean it
const cleanedGroup = removeActionFilters(filter.resource);
if (cleanedGroup.length > 0) {
acc.push({
...filter,
resource: cleanedGroup,
});
}
// @ts-expect-error -- we're checking for an older type of filter
} else if (filter.resource.root.type !== "action") {
// If it's not an action filter, keep it
acc.push(filter);
}
// Action filters are implicitly removed by not being added to acc
return acc;
}, []);
// Ensure the first filter in the group has a null connector
return cleanedFilters.map((filter, index) => {
if (index === 0) {
return { ...filter, connector: null };
}
return filter;
});
}
async function runMigration(): Promise<void> {
await prisma.$transaction(
async (tx) => {
console.log("Starting the data migration...");
const segmentsToUpdate = await tx.segment.findMany({});
console.log(`Found ${segmentsToUpdate.length} total segments`);
let changedFiltersCount = 0;
const updatePromises = segmentsToUpdate.map((segment) => {
const updatedFilters = removeActionFilters(segment.filters);
if (JSON.stringify(segment.filters) !== JSON.stringify(updatedFilters)) {
changedFiltersCount++;
}
return tx.segment.update({
where: { id: segment.id },
data: { filters: updatedFilters },
});
});
await Promise.all(updatePromises);
console.log(`Successfully updated ${changedFiltersCount} segments`);
},
{
timeout: 180000, // 3 minutes
}
);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,115 +0,0 @@
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
async function runMigration(): Promise<void> {
const startTime = Date.now();
console.log("Starting data migration...");
await prisma.$transaction(
async (transactionPrisma) => {
// Step 1: Use raw SQL to bulk update responses where responseId is not null in displays
console.log("Running bulk update for responses with valid responseId...");
const rawQueryResult = await transactionPrisma.$executeRaw`
WITH updated_displays AS (
UPDATE public."Response" r
SET "displayId" = d.id
FROM public."Display" d
WHERE r.id = d."responseId"
RETURNING d.id
)
UPDATE public."Display"
SET "responseId" = NULL
WHERE id IN (SELECT id FROM updated_displays);
`;
console.log("Bulk update completed!");
// Step 2: Handle the case where a display has a responseId but the corresponding response does not exist
console.log("Handling displays where the responseId exists but the response is missing...");
// Find displays where responseId is not null but the corresponding response does not exist
const displaysWithMissingResponses = await transactionPrisma.display.findMany({
where: {
responseId: {
not: null,
},
},
select: {
id: true,
responseId: true,
},
});
const responseIds = displaysWithMissingResponses
.map((display) => display.responseId)
.filter((id): id is string => id !== null);
// Check which of the responseIds actually exist in the responses table
const existingResponses = await transactionPrisma.response.findMany({
where: {
id: {
in: responseIds,
},
},
select: {
id: true,
},
});
const existingResponseIds = new Set(existingResponses.map((response) => response.id));
// Find displays where the responseId does not exist in the responses table
const displayIdsToDelete = displaysWithMissingResponses
.filter((display) => !existingResponseIds.has(display.responseId as unknown as string))
.map((display) => display.id);
if (displayIdsToDelete.length > 0) {
console.log(
`Deleting ${displayIdsToDelete.length.toString()} displays where the response is missing...`
);
await transactionPrisma.display.deleteMany({
where: {
id: {
in: displayIdsToDelete,
},
},
});
}
console.log("Displays where the response was missing have been deleted.");
console.log("Data migration completed.");
console.log(`Affected rows: ${rawQueryResult.toString() + displayIdsToDelete.length.toString()}`);
},
{
timeout: TRANSACTION_TIMEOUT,
}
);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,122 +0,0 @@
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
import {
type TSurveyAddressQuestion,
type TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
const prisma = new PrismaClient();
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
async function runMigration(): Promise<void> {
const startTime = Date.now();
console.log("Starting data migration...");
await prisma.$transaction(
async (transactionPrisma) => {
const surveysWithAddressQuestion = await transactionPrisma.survey.findMany({
where: {
questions: {
array_contains: [{ type: "address" }],
},
},
});
console.log(`Found ${surveysWithAddressQuestion.length.toString()} surveys with address questions`);
const updationPromises = [];
for (const survey of surveysWithAddressQuestion) {
const updatedQuestions = survey.questions.map((question: TSurveyQuestion) => {
if (question.type === TSurveyQuestionTypeEnum.Address) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- addressLine1 is not defined for unmigrated surveys
if (question.addressLine1 !== undefined) {
return null;
}
const {
isAddressLine1Required,
isAddressLine2Required,
isCityRequired,
isStateRequired,
isZipRequired,
isCountryRequired,
...rest
} = question as TSurveyAddressQuestion & {
isAddressLine1Required: boolean;
isAddressLine2Required: boolean;
isCityRequired: boolean;
isStateRequired: boolean;
isZipRequired: boolean;
isCountryRequired: boolean;
};
return {
...rest,
addressLine1: { show: true, required: isAddressLine1Required },
addressLine2: { show: true, required: isAddressLine2Required },
city: { show: true, required: isCityRequired },
state: { show: true, required: isStateRequired },
zip: { show: true, required: isZipRequired },
country: { show: true, required: isCountryRequired },
};
}
return question;
});
const isUpdationNotRequired = updatedQuestions.some(
(question: TSurveyQuestion | null) => question === null
);
if (!isUpdationNotRequired) {
updationPromises.push(
transactionPrisma.survey.update({
where: {
id: survey.id,
},
data: {
questions: updatedQuestions.filter((question: TSurveyQuestion | null) => question !== null),
},
})
);
}
}
if (updationPromises.length === 0) {
console.log("No surveys require migration... Exiting");
return;
}
await Promise.all(updationPromises);
console.log("Total surveys updated: ", updationPromises.length.toString());
},
{
timeout: TRANSACTION_TIMEOUT,
}
);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,75 +0,0 @@
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
async function runMigration(): Promise<void> {
const startTime = Date.now();
console.log("Starting data migration...");
await prisma.$transaction(
async (transactionPrisma) => {
const websiteSurveys = await transactionPrisma.survey.findMany({
where: { type: "website" },
});
const updationPromises = [];
for (const websiteSurvey of websiteSurveys) {
updationPromises.push(
transactionPrisma.survey.update({
where: { id: websiteSurvey.id },
data: {
type: "app",
segment: {
connectOrCreate: {
where: {
environmentId_title: {
environmentId: websiteSurvey.environmentId,
title: websiteSurvey.id,
},
},
create: {
title: websiteSurvey.id,
isPrivate: true,
environmentId: websiteSurvey.environmentId,
},
},
},
},
})
);
}
await Promise.all(updationPromises);
console.log(`Updated ${websiteSurveys.length.toString()} website surveys to app surveys`);
},
{
timeout: TRANSACTION_TIMEOUT,
}
);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,270 +0,0 @@
/* eslint-disable no-constant-condition -- Required for the while loop */
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Required for a while loop here */
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
async function runMigration(): Promise<void> {
const startTime = Date.now();
console.log("Starting data migration...");
await prisma.$transaction(
async (tx) => {
const totalContacts = await tx.contact.count();
// Check if any contacts still have a userId
const contactsWithUserId = await tx.contact.count({
where: {
userId: { not: null },
},
});
// If no contacts have a userId, migration is already complete
if (totalContacts > 0 && contactsWithUserId === 0) {
console.log("Migration already completed. No contacts with userId found.");
return;
}
const BATCH_SIZE = 10000; // Adjust based on your system's capacity
let skip = 0;
while (true) {
// Ensure email, firstName, lastName attributeKeys exist for all environments
const allEnvironmentsInBatch = await tx.environment.findMany({
select: { id: true },
skip,
take: BATCH_SIZE,
});
if (allEnvironmentsInBatch.length === 0) {
break;
}
console.log("Processing attributeKeys for", allEnvironmentsInBatch.length, "environments");
for (const env of allEnvironmentsInBatch) {
await tx.environment.update({
where: { id: env.id },
data: {
attributeKeys: {
upsert: [
{
where: {
key_environmentId: {
key: "email",
environmentId: env.id,
},
},
update: {
type: "default",
isUnique: true,
},
create: {
key: "email",
name: "Email",
description: "The email of a contact",
type: "default",
isUnique: true,
},
},
{
where: {
key_environmentId: {
key: "firstName",
environmentId: env.id,
},
},
update: {
type: "default",
},
create: {
key: "firstName",
name: "First Name",
description: "Your contact's first name",
type: "default",
},
},
{
where: {
key_environmentId: {
key: "lastName",
environmentId: env.id,
},
},
update: {
type: "default",
},
create: {
key: "lastName",
name: "Last Name",
description: "Your contact's last name",
type: "default",
},
},
{
where: {
key_environmentId: {
key: "userId",
environmentId: env.id,
},
},
update: {
type: "default",
isUnique: true,
},
create: {
key: "userId",
name: "User ID",
description: "The user ID of a contact",
type: "default",
isUnique: true,
},
},
],
},
},
});
}
skip += allEnvironmentsInBatch.length;
}
const CONTACTS_BATCH_SIZE = 20000;
let processedContacts = 0;
// delete userIds for these environments:
const { count } = await tx.contactAttribute.deleteMany({
where: {
attributeKey: {
key: "userId",
},
},
});
console.log("Deleted userId attributes for", count, "contacts");
while (true) {
const contacts = await tx.contact.findMany({
take: CONTACTS_BATCH_SIZE,
select: {
id: true,
userId: true,
environmentId: true,
},
where: {
userId: { not: null },
},
});
if (contacts.length === 0) {
break;
}
const environmentIdsByContacts = contacts.map((c) => c.environmentId);
const attributeMap = new Map<string, string>();
const userIdAttributeKeys = await tx.contactAttributeKey.findMany({
where: {
key: "userId",
environmentId: {
in: environmentIdsByContacts,
},
},
select: { id: true, environmentId: true },
});
userIdAttributeKeys.forEach((ak) => {
attributeMap.set(ak.environmentId, ak.id);
});
// Insert contactAttributes in bulk
await tx.contactAttribute.createMany({
data: contacts.map((contact) => {
if (!contact.userId) {
throw new Error(`Contact with id ${contact.id} has no userId`);
}
const userIdAttributeKey = attributeMap.get(contact.environmentId);
if (!userIdAttributeKey) {
throw new Error(`Attribute key for userId not found for environment ${contact.environmentId}`);
}
return {
contactId: contact.id,
value: contact.userId,
attributeKeyId: userIdAttributeKey,
};
}),
});
await tx.contact.updateMany({
where: {
id: { in: contacts.map((c) => c.id) },
},
data: {
userId: null,
},
});
processedContacts += contacts.length;
if (processedContacts > 0) {
console.log(`Processed ${processedContacts.toString()} contacts`);
}
}
const totalContactsAfterMigration = await tx.contact.count();
console.log("Total contacts after migration:", totalContactsAfterMigration);
// total attributes with userId:
const totalAttributes = await tx.contactAttribute.count({
where: {
attributeKey: {
key: "userId",
},
},
});
console.log("Total attributes with userId now:", totalAttributes);
if (totalContactsAfterMigration !== totalAttributes) {
throw new Error(
"Data migration failed. Total contacts after migration does not match total attributes with userId"
);
}
},
{
timeout: TRANSACTION_TIMEOUT,
}
);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,103 +0,0 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Required for a while loop here */
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
import type { TBaseFilters, TSegmentAttributeFilter, TSegmentFilter } from "../../../types/segment";
const prisma = new PrismaClient();
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
export const isResourceFilter = (resource: TSegmentFilter | TBaseFilters): resource is TSegmentFilter => {
return (resource as TSegmentFilter).root !== undefined;
};
const findAndReplace = (filters: TBaseFilters): TBaseFilters => {
const newFilters: TBaseFilters = [];
for (const filter of filters) {
if (isResourceFilter(filter.resource)) {
let { root } = filter.resource;
if (root.type === "attribute") {
// @ts-expect-error -- Legacy type
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- Legacy type
if (root.attributeClassName as string) {
root = {
type: "attribute",
// @ts-expect-error -- Legacy type
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Legacy type
contactAttributeKey: root.attributeClassName,
};
const newFilter = {
...filter.resource,
root,
} as TSegmentAttributeFilter;
newFilters.push({
...filter,
resource: newFilter,
});
}
} else {
newFilters.push(filter);
}
} else {
const updatedResource = findAndReplace(filter.resource);
newFilters.push({
...filter,
resource: updatedResource,
});
}
}
return newFilters;
};
async function runMigration(): Promise<void> {
const startTime = Date.now();
console.log("Starting data migration...");
await prisma.$transaction(
async (tx) => {
const allSegments = await tx.segment.findMany();
const updationPromises = [];
for (const segment of allSegments) {
updationPromises.push(
tx.segment.update({
where: { id: segment.id },
data: {
filters: findAndReplace(segment.filters),
},
})
);
}
await Promise.all(updationPromises);
},
{
timeout: TRANSACTION_TIMEOUT,
}
);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,143 +0,0 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Required for a while loop here */
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
async function runMigration(): Promise<void> {
const startTime = Date.now();
console.log("Starting data migration...");
await prisma.$transaction(
async (tx) => {
const emailAttributes = await tx.contactAttribute.findMany({
where: {
attributeKey: {
key: "email",
},
},
select: {
id: true,
value: true,
contact: {
select: {
id: true,
environmentId: true,
createdAt: true,
},
},
createdAt: true,
},
orderBy: {
createdAt: "asc", // Keep oldest attribute
},
});
// 2. Group by environment and email
const emailsByEnvironment: Record<
// environmentId key
string,
// email record
Record<string, { id: string; contactId: string; createdAt: Date }[]>
> = {};
// Group attributes by environment and email
for (const attr of emailAttributes) {
const { environmentId } = attr.contact;
const email = attr.value;
if (!emailsByEnvironment[environmentId]) {
emailsByEnvironment[environmentId] = {};
}
if (!emailsByEnvironment[environmentId][email]) {
emailsByEnvironment[environmentId][email] = [];
}
emailsByEnvironment[environmentId][email].push({
id: attr.id,
contactId: attr.contact.id,
createdAt: attr.createdAt,
});
}
// 3. Identify and delete duplicates
const deletionSummary: Record<
string,
{
email: string;
deletedAttributeIds: string[];
keptAttributeId: string;
}[]
> = {};
for (const [environmentId, emailGroups] of Object.entries(emailsByEnvironment)) {
deletionSummary[environmentId] = [];
for (const [email, attributes] of Object.entries(emailGroups)) {
if (attributes.length > 1) {
// Sort by createdAt to ensure we keep the oldest
attributes.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
// Keep the first (oldest) attribute and delete the rest
const [kept, ...duplicates] = attributes;
const duplicateIds = duplicates.map((d) => d.id);
// Delete duplicate attributes
await tx.contactAttribute.deleteMany({
where: {
id: {
in: duplicateIds,
},
},
});
deletionSummary[environmentId].push({
email,
deletedAttributeIds: duplicateIds,
keptAttributeId: kept.id,
});
}
}
}
// 4. Return summary of what was cleaned up
const summary = {
totalDuplicateAttributesRemoved: Object.values(deletionSummary).reduce(
(acc, env) => acc + env.reduce((sum, item) => sum + item.deletedAttributeIds.length, 0),
0
),
};
console.log("Data migration completed. Summary: ", summary);
},
{
timeout: TRANSACTION_TIMEOUT,
}
);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,326 +0,0 @@
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
interface TInvite {
organizationId: string;
deprecatedRole: "owner" | "admin" | "editor" | "developer" | "viewer";
email: string;
id: string;
creatorId: string;
createdAt: Date;
expiresAt: Date;
name?: string | null | undefined;
acceptorId?: string;
}
async function runMigration(): Promise<void> {
const startTime = Date.now();
console.log("Starting data migration...");
await prisma.$transaction(
async (transactionPrisma) => {
// Fetch all invites and group them by organizationId
const invites = (await transactionPrisma.invite.findMany({
select: {
id: true,
organizationId: true,
deprecatedRole: true,
},
})) as Pick<TInvite, "id" | "deprecatedRole" | "organizationId">[];
// Group invites by organizationId
const groupInvitesMap = new Map<
string,
{ id: string; organizationId: string; deprecatedRole: TInvite["deprecatedRole"] }[]
>();
invites.forEach((invite) => {
if (!groupInvitesMap.has(invite.organizationId)) {
groupInvitesMap.set(invite.organizationId, []);
}
groupInvitesMap.get(invite.organizationId)?.push(invite);
});
const groupInvites = groupInvitesMap.entries();
// Process each organization's invites to update roles accordingly
await Promise.all(
Array.from(groupInvites).map(async ([organizationId, organizationInvites]) => {
const adminInvites = organizationInvites.filter((invite) => invite.deprecatedRole === "admin");
const otherRoles = organizationInvites.filter((invite) => invite.deprecatedRole !== "admin");
// If no admin invites exist, skip this organization
if (adminInvites.length === 0) {
return;
}
// Update admin invites to "manager" if there are non-admin roles
if (otherRoles.length > 0) {
return transactionPrisma.invite.updateMany({
where: {
id: {
in: adminInvites.map((invite) => invite.id),
},
},
data: {
role: "manager",
},
});
}
// Check if there are other memberships (editor, developer, viewer)
const otherMembershipsCount = await transactionPrisma.membership.count({
where: {
organizationId,
deprecatedRole: {
in: ["editor", "developer", "viewer"],
},
},
});
// If there are other memberships, update admin invites to "manager"
if (otherMembershipsCount > 0) {
return transactionPrisma.invite.updateMany({
where: {
id: {
in: adminInvites.map((invite) => invite.id),
},
},
data: {
role: "manager",
},
});
}
// If no other memberships exist, promote admins to "owner", case where the organization has only owner and admin memberships as well as invite
return transactionPrisma.invite.updateMany({
where: {
id: {
in: adminInvites.map((invite) => invite.id),
},
},
data: {
role: "owner",
},
});
})
);
// Set all invites with roles of editor, developer, or viewer to "member"
await transactionPrisma.invite.updateMany({
where: {
deprecatedRole: {
in: ["editor", "developer", "viewer"],
},
},
data: {
role: "member",
},
});
// Fetch non-owner memberships and group them by organizationId
const nonOwnerMemberships = await transactionPrisma.membership.findMany({
where: {
role: {
notIn: ["owner"],
},
},
select: {
userId: true,
organizationId: true,
deprecatedRole: true,
organization: {
select: {
invites: {
where: {
deprecatedRole: {
not: "admin",
},
},
select: {
deprecatedRole: true,
},
},
},
},
},
});
const groupedMemberships = new Map<string, typeof nonOwnerMemberships>();
const otherInvitesCount = new Map<string, number>();
nonOwnerMemberships.forEach((membership) => {
if (!groupedMemberships.has(membership.organizationId)) {
groupedMemberships.set(membership.organizationId, []);
}
if (!otherInvitesCount.has(membership.organizationId)) {
otherInvitesCount.set(membership.organizationId, membership.organization.invites.length);
}
groupedMemberships.get(membership.organizationId)?.push(membership);
});
const groupedMembershipsEntries = groupedMemberships.entries();
// Process each organization's memberships to update or create teams
await Promise.all(
Array.from(groupedMembershipsEntries).map(async ([organizationId, memberships]) => {
const adminMembership = memberships.filter((membership) => membership.deprecatedRole === "admin");
const developerMembership = memberships.filter(
(membership) => membership.deprecatedRole === "developer"
);
const editorMembership = memberships.filter((membership) => membership.deprecatedRole === "editor");
const viewerMembership = memberships.filter((membership) => membership.deprecatedRole === "viewer");
const otherMemberships =
developerMembership.length + editorMembership.length + viewerMembership.length;
// If admin members exist alongside others, set their role to "manager"
if (adminMembership.length) {
const otherInvites = otherInvitesCount.get(organizationId) ?? 0;
await transactionPrisma.membership.updateMany({
where: {
organizationId,
deprecatedRole: "admin",
},
data: {
role: otherMemberships || otherInvites > 0 ? "manager" : "owner",
},
});
}
// Create team and update roles for developer or editor memberships
if (developerMembership.length || editorMembership.length || viewerMembership.length) {
const productIdsInOrganization = await transactionPrisma.product.findMany({
where: {
organizationId,
},
select: {
id: true,
},
});
// Create an "all access" team for developer and editor roles
if (developerMembership.length || editorMembership.length) {
await transactionPrisma.team.create({
data: {
organizationId,
name: "all access",
teamUsers: {
create: [...developerMembership, ...editorMembership].map((membership) => ({
userId: membership.userId,
role: "contributor",
})),
},
productTeams: {
create: productIdsInOrganization.map((product) => ({
productId: product.id,
permission: "manage",
})),
},
},
});
await transactionPrisma.membership.updateMany({
where: {
organizationId,
deprecatedRole: {
in: ["developer", "editor"],
},
},
data: {
role: "member",
},
});
}
// Create a "read only" team for viewer roles
if (viewerMembership.length) {
await transactionPrisma.team.create({
data: {
organizationId,
name: "read only",
teamUsers: {
create: viewerMembership.map((membership) => ({
userId: membership.userId,
role: "contributor",
})),
},
productTeams: {
create: productIdsInOrganization.map((product) => ({
productId: product.id,
permission: "read",
})),
},
},
});
await transactionPrisma.membership.updateMany({
where: {
organizationId,
deprecatedRole: "viewer",
},
data: {
role: "member",
},
});
}
}
})
);
await transactionPrisma.membership.updateMany({
where: {
deprecatedRole: "owner",
},
data: {
role: "owner",
},
});
// Clear out the old "role" field in invites after migration
await transactionPrisma.invite.updateMany({
data: {
deprecatedRole: null,
},
});
await transactionPrisma.membership.updateMany({
data: {
deprecatedRole: null,
},
});
},
{
timeout: TRANSACTION_TIMEOUT,
}
);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,183 +0,0 @@
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const TRANSACTION_TIMEOUT = 3 * 60 * 1000; // 3 minutes in milliseconds
type Plan = "free" | "startup" | "scale";
interface TOrganization {
id: string;
billing: {
plan: Plan;
limits: {
monthly: {
responses: number;
miu: number;
};
};
};
}
export const BILLING_LIMITS = {
free: {
RESPONSES: 1500,
MIU: 2000,
},
startup: {
RESPONSES: 5000,
MIU: 7500,
},
scale: {
RESPONSES: 10000,
MIU: 30000,
},
} as const;
async function runMigration(): Promise<void> {
const startTime = Date.now();
console.log("Starting data migration...");
await prisma.$transaction(
async (transactionPrisma) => {
const organizations = (await transactionPrisma.organization.findMany({
where: {
OR: [
{
AND: [
{
billing: {
path: ["plan"],
equals: "free",
},
},
{
billing: {
path: ["limits", "monthly", "miu"],
not: 2000,
},
},
{
billing: {
path: ["limits", "monthly", "responses"],
not: 1500,
},
},
],
},
{
AND: [
{
billing: {
path: ["plan"],
equals: "startup",
},
},
{
billing: {
path: ["limits", "monthly", "miu"],
not: 7500,
},
},
{
billing: {
path: ["limits", "monthly", "responses"],
not: 5000,
},
},
],
},
{
AND: [
{
billing: {
path: ["plan"],
equals: "scale",
},
},
{
billing: {
path: ["limits", "monthly", "miu"],
not: 30000,
},
},
{
billing: {
path: ["limits", "monthly", "responses"],
not: 10000,
},
},
],
},
],
},
select: {
id: true,
billing: true,
},
})) as TOrganization[];
const updationPromises = [];
for (const organization of organizations) {
const plan = organization.billing.plan;
const limits = BILLING_LIMITS[plan];
let billing = organization.billing;
billing = {
...billing,
limits: {
...billing.limits,
monthly: {
...billing.limits.monthly,
responses: limits.RESPONSES,
miu: limits.MIU,
},
},
};
const updatePromise = transactionPrisma.organization.update({
where: {
id: organization.id,
},
data: {
billing,
},
});
updationPromises.push(updatePromise);
}
await Promise.all(updationPromises);
console.log(`Updated ${organizations.length.toString()} organizations`);
},
{
timeout: TRANSACTION_TIMEOUT,
}
);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -1,99 +0,0 @@
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { Prisma, PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const TRANSACTION_TIMEOUT = 3 * 60 * 1000; // 3 minutes in milliseconds
type Plan = "free" | "startup" | "scale" | "enterprise";
const projectsLimitByPlan: Record<Plan, number | null> = {
free: 3,
startup: null,
scale: null,
enterprise: null,
};
async function runMigration(): Promise<void> {
const startTime = Date.now();
console.log("Starting data migration...");
await prisma.$transaction(
async (transactionPrisma) => {
const organizations = await transactionPrisma.organization.findMany({
where: {
billing: {
path: ["limits", "projects"],
equals: Prisma.DbNull,
},
},
select: {
id: true,
billing: true,
},
});
const updateOrganizationPromises = organizations.map((org) =>
transactionPrisma.organization.update({
where: {
id: org.id,
},
data: {
billing: {
...org.billing,
limits: {
...org.billing.limits,
projects: projectsLimitByPlan[org.billing.plan as Plan],
},
},
},
})
);
await Promise.all(updateOrganizationPromises);
console.log(`Updated ${updateOrganizationPromises.length.toString()} organizations`);
const updatedemptyConfigProjects = await transactionPrisma.project.updateMany({
where: {
config: {
equals: {},
},
},
data: {
config: {
channel: null,
industry: null,
},
},
});
console.log(`Updated ${updatedemptyConfigProjects.count.toString()} projects with empty config`);
},
{
timeout: TRANSACTION_TIMEOUT,
}
);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();

Some files were not shown because too many files have changed in this diff Show More