mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-02 03:15:05 -05:00
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:
@@ -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: |
|
||||
|
||||
@@ -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.
|
||||
|
||||
That’s 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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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:*",
|
||||
|
||||
@@ -4,4 +4,7 @@ module.exports = {
|
||||
project: "tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
},
|
||||
};
|
||||
|
||||
-110
@@ -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());
|
||||
-44
@@ -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());
|
||||
-58
@@ -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());
|
||||
-52
@@ -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());
|
||||
-102
@@ -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());
|
||||
-262
@@ -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;
|
||||
};
|
||||
-62
@@ -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());
|
||||
-144
@@ -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();
|
||||
});
|
||||
-205
@@ -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();
|
||||
});
|
||||
-123
@@ -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();
|
||||
});
|
||||
-91
@@ -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();
|
||||
});
|
||||
-109
@@ -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());
|
||||
-119
@@ -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();
|
||||
-73
@@ -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();
|
||||
-113
@@ -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();
|
||||
-111
@@ -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();
|
||||
-107
@@ -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();
|
||||
-316
@@ -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();
|
||||
-88
@@ -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();
|
||||
-115
@@ -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();
|
||||
-122
@@ -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();
|
||||
-75
@@ -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();
|
||||
-270
@@ -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();
|
||||
-103
@@ -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();
|
||||
-143
@@ -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
Reference in New Issue
Block a user