ParabolInc / parabol

Free online agile retrospective meeting tool
https://www.parabol.co/
Other
1.9k stars 327 forks source link

Refactor featureFlags proposal #10099

Open nickoferrall opened 1 month ago

nickoferrall commented 1 month ago

Problems

Today, featureFlags are an array of enums in both the organization and user tables. Soon, we may want to add a team feature flag too, e.g. Team Insights.

Some flags exist in both tables, e.g. noAISummary.

Feature flags stay in the app until we decide to remove them. zoomTranscription and standupAISummary, for example, have been in the app for over a year. This leads to our experience using the Parabol org to be quite different to the experience of all other users.

Proposal

Create a featureFlag table with the following properties:

id (string)
featureName (enum)
isEnabled (boolean)
description (string, optional)
userId (string, optional)
orgId (string, optional)
teamId (string, optional)
expirationDate (timestamp, optional)
createdAt (timestamp)
updatedAt (timestamp)

Devs are encouraged to set an expiration date for the feature flag to force us to make a decision on a feature, but it's an optional field, so we don't have to include it if we're not sure.

In the server, we can check if the feature flag exists like so:

const Organization: OrganizationResolvers = {
  featureFlag: async ({ id: orgId }, { name }, { dataLoader }) => {
    const flags = await dataLoader.get('featureFlagsByOrgId').load(orgId);
    const flag = flags.find((flag) => flag.featureName === name);
    return !!(flag && flag.isEnabled && (!flag.expirationDate || new Date(flag.expirationDate) > new Date()));
  },

And in the client:

const organization = useFragment(
  graphql`
    fragment RetroDiscussionThreadHeader_organization on Organization {
      publicTeams: featureFlag(name: PUBLIC_TEAMS)
      relatedDiscussions: featureFlag(name: RELATED_DISCUSSIONS)
    }
  `,
);

const hasPublicTeamsFlag = organization.publicTeams;
const hasRelatedDiscussionsFlag = organization.relatedDiscussions;

Let me know what you think!

Dschoordsch commented 1 month ago

Why have userId and teamId if the interface only allows querying for orgId? Having featureName being an enum is a pain if we want to remove a flag again at some point.

nickoferrall commented 1 month ago

Why have userId and teamId if the interface only allows querying for orgId?

I was thinking we'd have a featureFlag field in Organization, User, and Team, but making it an interface instead makes sense.

Having featureName being an enum is a pain if we want to remove a flag again at some point.

Yeah, would you prefer it to be a string? My concern is that it'd be easy to make a typo

Dschoordsch commented 1 month ago

Yeah, would you prefer it to be a string? My concern is that it'd be easy to make a typo

I think we need to run a migration in any case, so we could add a check on a string rather than a full blown enum type. But maybe there are cleaner ways?

mattkrick commented 1 month ago
nickoferrall commented 1 month ago

A FeatureFlagOwner crosstable makes sense to me! The description and expirationDate should always be the same, so that'll prevent redundant data.

Updated Proposal

Create a FeatureFlag table:

id (string) 
featureName (string) 
scope (enum) -- 'User', 'Team', 'Organization'
description (string, optional)
expiresAt (timestamp) -- non-null
createdAt (timestamp)
updatedAt (timestamp)

Create a FeatureFlagOwner crosstable:

id (string) 
featureFlagId (string)
userId (string, optional) -- mutually exclusive with teamId and orgId
teamId (string, optional) -- mutually exclusive with userId and orgId
orgId (string, optional) -- mutually exclusive with userId and orgId
createdAt (timestamp)
updatedAt (timestamp)

We could just have an ownerId in the crosstable, saving two columns, but specifying orgId, teamId, and userId is more explicit.

In the server:

const Organization: OrganizationResolvers = {
  featureFlag: async ({ id: orgId }, { name }, { dataLoader }) => {
    if (!validFeatureFlagNames.includes(name)) {
      return standardError(...);
    }
    const flags = await dataLoader.get('featureFlagsByOwnerId').load(orgId);
    const flag = flags.find((flag) => flag.featureName === name);
    return !!(flag && new Date(flag.expiresAt) > new Date());
  }
};

const User: UserResolvers = {
  featureFlag: async ({ id: userId }, { name }, { dataLoader }) => {
    if (!validFeatureFlagNames.includes(name)) {
      return standardError(...);
    }
    const flags = await dataLoader.get('featureFlagsByOwnerId').load(userId);
    const flag = flags.find((flag) => flag.featureName === name);
    return !!(flag && new Date(flag.expiresAt) > new Date());
  }
};

In the featureFlagsByOwnerId dataLoader, we'd join the two tables, and check where scope === User && userId === ownerId or scope === Organization && orgId === ownerId, etc.

In the client:

const organization = useFragment(
  graphql`
    fragment RetroDiscussionThreadHeader_organization on Organization {
      publicTeams: featureFlag(name: "publicTeams")
      relatedDiscussions: featureFlag(name: "relatedDiscussions")
    }
  `,
);

const hasPublicTeamsFlag = organization.publicTeams;
const hasRelatedDiscussionsFlag = organization.relatedDiscussions;
nickoferrall commented 4 weeks ago

Hey @mattkrick, it'd be great to get your thoughts on this! I can then implement it for insights

nickoferrall commented 2 weeks ago

Hey @mattkrick, bumping this one 🙏

mattkrick commented 2 weeks ago

thanks for the ping! this looks good. we can probably get rid of updatedAt and id on the cross table since we won't use any lookups by id & these rows are going to be inserted or deleted only.

On the crosstable, i like the thought of keeping the FKs separate. It's explicit and doesn't require GUIDs. On the other hand, we have GUIDs, it'll require 3 indexes. I think your solution is a good one!

One note-- the query will be a little expensive, so make sure to run the explain on it to make sure you're using all the indexes you can. For example, on TeamMember you'll have to include and "isNotRemoved" = true in order to use the index since we only index on active team members.