(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
This commit is contained in:
Jarosław Sadziński 2024-06-18 12:12:20 +02:00
parent 14a868c460
commit 76d94483ad
3 changed files with 232 additions and 0 deletions

View File

@ -1,8 +1,10 @@
import { ApiError } from 'app/common/ApiError'; import { ApiError } from 'app/common/ApiError';
import { delay } from 'app/common/delay'; import { delay } from 'app/common/delay';
import { buildUrlId } from 'app/common/gristUrls'; import { buildUrlId } from 'app/common/gristUrls';
import { BillingAccount } from 'app/gen-server/entity/BillingAccount';
import { Document } from 'app/gen-server/entity/Document'; import { Document } from 'app/gen-server/entity/Document';
import { Organization } from 'app/gen-server/entity/Organization'; 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 { Workspace } from 'app/gen-server/entity/Workspace';
import { HomeDBManager, Scope } from 'app/gen-server/lib/HomeDBManager'; import { HomeDBManager, Scope } from 'app/gen-server/lib/HomeDBManager';
import { fromNow } from 'app/gen-server/sqlUtils'; import { fromNow } from 'app/gen-server/sqlUtils';
@ -462,3 +464,79 @@ async function forEachWithBreaks<T>(logText: string, items: T[], callback: (item
} }
log.rawInfo(logText, {itemsProcesssed, itemsTotal, timeMs: Date.now() - start}); 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;
});
}

View File

@ -7,6 +7,7 @@
import {commonUrls} from 'app/common/gristUrls'; import {commonUrls} from 'app/common/gristUrls';
import {isAffirmative} from 'app/common/gutil'; import {isAffirmative} from 'app/common/gutil';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; 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); 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) { if (process.env.GRIST_SERVE_PLUGINS_PORT) {
await server.startCopy('pluginServer', parseInt(process.env.GRIST_SERVE_PLUGINS_PORT, 10)); await server.startCopy('pluginServer', parseInt(process.env.GRIST_SERVE_PLUGINS_PORT, 10));
} }
await fixSiteProducts({
deploymentType: server.getDeploymentType(),
db: server.getHomeDBManager(),
dry: true
});
return server; return server;
} }

View File

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