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'); + }); +});