From 76d94483adcf22f0249012e4ef2a8297955bb50a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Tue, 18 Jun 2024 12:12:20 +0200 Subject: [PATCH] (core) Adding fixSiteProducts that changes orgs from teamFree to Free product if it was set be default Summary: After release on 2024-06-12 (1.1.15) the GRIST_DEFAULT_PRODUCT env variable wasn't respected by the method that started the server in single org mode. In all deployments (apart from saas), the default product used for new sites is set to `Free`, but the code that starts the server enforced `teamFree` product. This change adds a fix routine that fixes this issue by rewriting team sites from `teamFree` product to `Free` product only if: - The default product is set to `Free` - The deployment type is something other then 'saas'. Additionally there is a test that will fail after 2024.10.01, as this fix should be removed before this date. Test Plan: Added test Reviewers: paulfitz Reviewed By: paulfitz Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D4272 --- app/gen-server/lib/Housekeeper.ts | 78 ++++++++++++++++ stubs/app/server/server.ts | 8 ++ test/server/fixSiteProducts.ts | 146 ++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 test/server/fixSiteProducts.ts diff --git a/app/gen-server/lib/Housekeeper.ts b/app/gen-server/lib/Housekeeper.ts index f3412d03..14c68ecf 100644 --- a/app/gen-server/lib/Housekeeper.ts +++ b/app/gen-server/lib/Housekeeper.ts @@ -1,8 +1,10 @@ import { ApiError } from 'app/common/ApiError'; import { delay } from 'app/common/delay'; import { buildUrlId } from 'app/common/gristUrls'; +import { BillingAccount } from 'app/gen-server/entity/BillingAccount'; import { Document } from 'app/gen-server/entity/Document'; import { Organization } from 'app/gen-server/entity/Organization'; +import { Product } from 'app/gen-server/entity/Product'; import { Workspace } from 'app/gen-server/entity/Workspace'; import { HomeDBManager, Scope } from 'app/gen-server/lib/HomeDBManager'; import { fromNow } from 'app/gen-server/sqlUtils'; @@ -462,3 +464,79 @@ async function forEachWithBreaks(logText: string, items: T[], callback: (item } log.rawInfo(logText, {itemsProcesssed, itemsTotal, timeMs: Date.now() - start}); } + +/** + * For a brief moment file `stubs/app/server/server.ts` was ignoring the GRIST_DEFAULT_PRODUCT + * variable, which is currently set for all deployment types to 'Free' product. As a result orgs + * created after 2024-06-12 (1.1.15) were created with 'teamFree' product instead of 'Free'. + * It only affected deployments that were using: + * - GRIST_DEFAULT_PRODUCT variable set to 'Free' + * - GRIST_SINGLE_ORG set to enforce single org mode. + * + * This method fixes the product for all orgs created with 'teamFree' product, if the default + * product that should be used is 'Free' and the deployment type is not 'saas' ('saas' deployment + * isn't using GRIST_DEFAULT_PRODUCT variable). This method should be removed after 2024.10.01. + * + * There is a corresponding test that will fail if this method (and that test) are not removed. + * + * @returns true if the method was run, false otherwise. + */ +export async function fixSiteProducts(options: { + deploymentType: string, + db: HomeDBManager, + dry?: boolean, +}) { + const {deploymentType, dry, db} = options; + + const hasDefaultProduct = () => Boolean(process.env.GRIST_DEFAULT_PRODUCT); + const defaultProductIsFree = () => process.env.GRIST_DEFAULT_PRODUCT === 'Free'; + const notSaasDeployment = () => deploymentType !== 'saas'; + const mustRun = hasDefaultProduct() && defaultProductIsFree() && notSaasDeployment(); + if (!mustRun) { + return false; + } + const removeMeDate = new Date('2024-10-01'); + const warningMessage = `WARNING: This method should be removed after ${removeMeDate.toDateString()}.`; + if (new Date() > removeMeDate) { + console.warn(warningMessage); + } + + // Find all billing accounts on teamFree product and change them to the Free. + + return await db.connection.transaction(async (t) => { + const freeProduct = await t.findOne(Product, {where: {name: 'Free'}}); + + const freeTeamProduct = await t.findOne(Product, {where: {name: 'teamFree'}}); + + if (!freeTeamProduct) { + console.warn('teamFree product not found.'); + return false; + } + + if (!freeProduct) { + console.warn('Free product not found.'); + return false; + } + + if (dry) { + await t.createQueryBuilder() + .select('ba') + .from(BillingAccount, 'ba') + .where('ba.product = :productId', {productId: freeTeamProduct.id}) + .getMany() + .then((accounts) => { + accounts.forEach(a => { + console.log(`Would change account ${a.id} from ${a.product.id} to ${freeProduct.id}`); + }); + }); + } else { + await t.createQueryBuilder() + .update(BillingAccount) + .set({product: freeProduct.id}) + .where({product: freeTeamProduct.id}) + .execute(); + } + + return true; + }); +} diff --git a/stubs/app/server/server.ts b/stubs/app/server/server.ts index 20ab2d26..b6f272a9 100644 --- a/stubs/app/server/server.ts +++ b/stubs/app/server/server.ts @@ -7,6 +7,7 @@ import {commonUrls} from 'app/common/gristUrls'; import {isAffirmative} from 'app/common/gutil'; import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; +import {fixSiteProducts} from 'app/gen-server/lib/Housekeeper'; const debugging = isAffirmative(process.env.DEBUG) || isAffirmative(process.env.VERBOSE); @@ -135,6 +136,13 @@ export async function main() { if (process.env.GRIST_SERVE_PLUGINS_PORT) { await server.startCopy('pluginServer', parseInt(process.env.GRIST_SERVE_PLUGINS_PORT, 10)); } + + await fixSiteProducts({ + deploymentType: server.getDeploymentType(), + db: server.getHomeDBManager(), + dry: true + }); + return server; } diff --git a/test/server/fixSiteProducts.ts b/test/server/fixSiteProducts.ts new file mode 100644 index 00000000..b42d5a01 --- /dev/null +++ b/test/server/fixSiteProducts.ts @@ -0,0 +1,146 @@ +import {Organization} from 'app/gen-server/entity/Organization'; +import {fixSiteProducts} from 'app/gen-server/lib/Housekeeper'; +import {TestServer} from 'test/gen-server/apiUtils'; +import * as testUtils from 'test/server/testUtils'; +import {assert} from 'chai'; +import sinon from "sinon"; +import {getDefaultProductNames} from 'app/gen-server/entity/Product'; + +const email = 'chimpy@getgrist.com'; +const profile = {email, name: email}; +const org = 'single-org'; + +describe('fixSiteProducts', function() { + this.timeout(6000); + + let oldEnv: testUtils.EnvironmentSnapshot; + let server: TestServer; + + before(async function() { + oldEnv = new testUtils.EnvironmentSnapshot(); + // By default we will simulate 'core' deployment that has 'Free' team site as default product. + process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'core'; + process.env.GRIST_DEFAULT_PRODUCT = 'Free'; + server = new TestServer(this); + await server.start(); + }); + + after(async function() { + oldEnv.restore(); + await server.stop(); + }); + + it('fix should be deleted after 2024-10-01', async function() { + const now = new Date(); + const remove_date = new Date('2024-10-01'); + assert.isTrue(now < remove_date, 'This test and a fix method should be deleted after 2024-10-01'); + }); + + it('fixes sites that where created with a wrong product', async function() { + const db = server.dbManager; + const user = await db.getUserByLogin(email, {profile}) as any; + const getOrg = (id: number) => db.connection.manager.findOne( + Organization, + {where: {id}, relations: ['billingAccount', 'billingAccount.product']}); + + const productOrg = (id: number) => getOrg(id)?.then(org => org?.billingAccount?.product?.name); + + const freeOrgId = db.unwrapQueryResult(await db.addOrg(user, { + name: org, + domain: org, + }, { + setUserAsOwner: false, + useNewPlan: true, + product: 'teamFree', + })); + + const teamOrgId = db.unwrapQueryResult(await db.addOrg(user, { + name: 'fix-team-org', + domain: 'fix-team-org', + }, { + setUserAsOwner: false, + useNewPlan: true, + product: 'team', + })); + + // Make sure it is created with teamFree product. + assert.equal(await productOrg(freeOrgId), 'teamFree'); + + // Run the fixer. + assert.isTrue(await fixSiteProducts({ + db, + deploymentType: server.server.getDeploymentType(), + })); + + // Make sure we fixed the product is on Free product. + assert.equal(await productOrg(freeOrgId), 'Free'); + + // Make sure the other org is still on team product. + assert.equal(await productOrg(teamOrgId), 'team'); + }); + + it("doesn't run when on saas deployment", async function() { + process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'saas'; + + // Stub it in the server. Notice that we assume some knowledge about how the server is implemented - that it won't + // cache this value (nor any other component) and always read it when needed. Otherwise we would need to recreate + // the server each time. + const sandbox = sinon.createSandbox(); + sandbox.stub(server.server, 'getDeploymentType').returns('saas'); + assert.equal(server.server.getDeploymentType(), 'saas'); + + assert.isFalse(await fixSiteProducts({ + db: server.dbManager, + deploymentType: server.server.getDeploymentType(), + })); + + sandbox.restore(); + }); + + it("doesn't run when default product is not set", async function() { + // Make sure we are in 'core'. + assert.equal(server.server.getDeploymentType(), 'core'); + + // But only when Free product is the default one. + process.env.GRIST_DEFAULT_PRODUCT = 'teamFree'; + assert.equal(getDefaultProductNames().teamInitial, 'teamFree'); // sanity check that Grist sees it. + + assert.isFalse(await fixSiteProducts({ + db: server.dbManager, + deploymentType: server.server.getDeploymentType(), + })); + + process.env.GRIST_DEFAULT_PRODUCT = 'team'; + assert.equal(getDefaultProductNames().teamInitial, 'team'); + + assert.isFalse(await fixSiteProducts({ + db: server.dbManager, + deploymentType: server.server.getDeploymentType(), + })); + + delete process.env.GRIST_DEFAULT_PRODUCT; + assert.equal(getDefaultProductNames().teamInitial, 'stub'); + + const db = server.dbManager; + const user = await db.getUserByLogin(email, {profile}) as any; + const orgId = db.unwrapQueryResult(await db.addOrg(user, { + name: 'sanity-check-org', + domain: 'sanity-check-org', + }, { + setUserAsOwner: false, + useNewPlan: true, + product: 'teamFree', + })); + + const getOrg = (id: number) => db.connection.manager.findOne(Organization, + {where: {id}, relations: ['billingAccount', 'billingAccount.product']}); + const productOrg = (id: number) => getOrg(id)?.then(org => org?.billingAccount?.product?.name); + assert.equal(await productOrg(orgId), 'teamFree'); + + assert.isFalse(await fixSiteProducts({ + db: server.dbManager, + deploymentType: server.server.getDeploymentType(), + })); + assert.equal(await productOrg(orgId), 'teamFree'); + }); +});