diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index c1758f65f..ae5bcdbd8 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -3696,6 +3696,7 @@ export enum UploadImportFileType { export type User = { __typename?: 'User'; email?: Maybe; + featureList?: Maybe>; features?: Maybe>>; followersCount?: Maybe; friendsCount?: Maybe; @@ -7139,6 +7140,7 @@ export type UploadImportFileSuccessResolvers = { email?: Resolver, ParentType, ContextType>; + featureList?: Resolver>, ParentType, ContextType>; features?: Resolver>>, ParentType, ContextType>; followersCount?: Resolver, ParentType, ContextType>; friendsCount?: Resolver, ParentType, ContextType>; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 104a34987..94695e350 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -2984,6 +2984,7 @@ enum UploadImportFileType { type User { email: String + featureList: [Feature!] features: [String] followersCount: Int friendsCount: Int diff --git a/packages/api/src/jobs/process-youtube-video.ts b/packages/api/src/jobs/process-youtube-video.ts index 2802e89a6..d8a067897 100644 --- a/packages/api/src/jobs/process-youtube-video.ts +++ b/packages/api/src/jobs/process-youtube-video.ts @@ -1,21 +1,19 @@ -import { logger } from '../utils/logger' +import { Storage } from '@google-cloud/storage' +import { PromptTemplate } from '@langchain/core/prompts' +import { OpenAI } from '@langchain/openai' +import { parseHTML } from 'linkedom' +import showdown from 'showdown' +import * as stream from 'stream' +import { Chapter, Client as YouTubeClient } from 'youtubei' +import { LibraryItem, LibraryItemState } from '../entity/library_item' +import { env } from '../env' import { authTrx } from '../repository' import { libraryItemRepository } from '../repository/library_item' -import { LibraryItem, LibraryItemState } from '../entity/library_item' - -import { Chapter, Client as YouTubeClient } from 'youtubei' -import showdown from 'showdown' -import { parseHTML } from 'linkedom' -import { parsePreparedContent } from '../utils/parser' -import { OpenAI } from '@langchain/openai' -import { PromptTemplate } from '@langchain/core/prompts' +import { FeatureName, findGrantedFeatureByName } from '../services/features' import { enqueueProcessYouTubeTranscript } from '../utils/createTask' -import { env } from '../env' -import * as stream from 'stream' - -import { Storage } from '@google-cloud/storage' import { stringToHash } from '../utils/helpers' -import { FeatureName, findFeatureByName } from '../services/features' +import { logger } from '../utils/logger' +import { parsePreparedContent } from '../utils/parser' export interface ProcessYouTubeVideoJobData { userId: string @@ -394,7 +392,10 @@ export const processYouTubeVideo = async ( } if ( - await findFeatureByName(FeatureName.YouTubeTranscripts, jobData.userId) + await findGrantedFeatureByName( + FeatureName.YouTubeTranscripts, + jobData.userId + ) ) { if ('getTranscript' in video && duration > 0 && duration < 1801) { // If the video has a transcript available, put a placehold in and diff --git a/packages/api/src/pubsub.ts b/packages/api/src/pubsub.ts index 237a5801f..2214d787b 100644 --- a/packages/api/src/pubsub.ts +++ b/packages/api/src/pubsub.ts @@ -3,7 +3,6 @@ import express from 'express' import { RuleEventType } from './entity/rule' import { env } from './env' import { ReportType } from './generated/graphql' -import { FeatureName, findFeatureByName } from './services/features' import { enqueueExportItem, enqueueProcessYouTubeVideo, @@ -87,12 +86,12 @@ export const createPubSubClient = (): PubsubClient => { }) if (type === EntityType.PAGE) { - if (await findFeatureByName(FeatureName.AISummaries, userId)) { - // await enqueueAISummarizeJob({ - // userId, - // libraryItemId, - // }) - } + // if (await findGrantedFeatureByName(FeatureName.AISummaries, userId)) { + // await enqueueAISummarizeJob({ + // userId, + // libraryItemId, + // }) + // } const isYoutubeVideo = (data: any): data is { originalUrl: string } => { return 'originalUrl' in data diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 8757c10a1..9a8fcfa58 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -368,6 +368,17 @@ export const functionResolvers = { } return undefined }, + async featureList( + _: User, + __: Record, + ctx: WithDataSourcesContext + ) { + if (!ctx.claims?.uid) { + return undefined + } + + return findUserFeatures(ctx.claims.uid) + }, async features( user: User, __: Record, @@ -376,7 +387,8 @@ export const functionResolvers = { if (!ctx.claims?.uid) { return undefined } - return findUserFeatures(ctx.claims.uid) + + return (await findUserFeatures(ctx.claims.uid)).map((f) => f.name) }, }, Article: { diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 42d234a7d..e106514e4 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -89,6 +89,7 @@ const schema = gql` source: String intercomHash: String features: [String] + featureList: [Feature!] } type Profile { diff --git a/packages/api/src/services/features.ts b/packages/api/src/services/features.ts index f6de25593..664461a50 100644 --- a/packages/api/src/services/features.ts +++ b/packages/api/src/services/features.ts @@ -7,7 +7,8 @@ import { getRepository } from '../repository' import { logger } from '../utils/logger' const MAX_ULTRA_REALISTIC_USERS = 1500 -const MAX_YOUTUBE_TRANSCRIPT_USERS = 100 +const MAX_YOUTUBE_TRANSCRIPT_USERS = 500 +const MAX_NOTION_USERS = 1000 export enum FeatureName { AISummaries = 'ai-summaries', @@ -38,7 +39,7 @@ export const optInFeature = async ( MAX_YOUTUBE_TRANSCRIPT_USERS ) case FeatureName.Notion: - return optInLimitedFeature(FeatureName.Notion, uid, 1) + return optInLimitedFeature(FeatureName.Notion, uid, MAX_NOTION_USERS) default: return undefined } @@ -121,23 +122,20 @@ export const signFeatureToken = ( ) } -export const findUserFeatures = async (userId: string): Promise => { - return ( - await getRepository(Feature).find({ - where: { - user: { id: userId }, - }, - }) - ).map((feature) => feature.name) +export const findUserFeatures = async (userId: string) => { + return getRepository(Feature).findBy({ + user: { id: userId }, + }) } -export const findFeatureByName = async ( +export const findGrantedFeatureByName = async ( name: FeatureName, userId: string ): Promise => { - return await getRepository(Feature).findOneBy({ + return getRepository(Feature).findOneBy({ name, user: { id: userId }, + grantedAt: Not(IsNull()), }) } diff --git a/packages/web/lib/featureFlag.ts b/packages/web/lib/featureFlag.ts index f79369b78..4cae69bf6 100644 --- a/packages/web/lib/featureFlag.ts +++ b/packages/web/lib/featureFlag.ts @@ -7,5 +7,10 @@ export const userHasFeature = ( if (!user) { return false } - return user.features.includes(feature) + return user.featureList.some( + (f) => + f.name === feature && + f.grantedAt && + (!f.expiresAt || new Date(f.expiresAt) > new Date()) + ) } diff --git a/packages/web/lib/networking/fragments/featureFragment.ts b/packages/web/lib/networking/fragments/featureFragment.ts new file mode 100644 index 000000000..78dea92f4 --- /dev/null +++ b/packages/web/lib/networking/fragments/featureFragment.ts @@ -0,0 +1,21 @@ +import { gql } from 'graphql-request' + +export const featureFragment = gql` + fragment FeatureFields on Feature { + id + name + createdAt + updatedAt + grantedAt + expiresAt + } +` + +export interface Feature { + id: string + name: string + createdAt: Date + updatedAt?: Date + grantedAt?: Date + expiresAt?: Date +} diff --git a/packages/web/lib/networking/mutations/optIntoFeatureMutation.ts b/packages/web/lib/networking/mutations/optIntoFeatureMutation.ts index 26ac49d47..27633c322 100644 --- a/packages/web/lib/networking/mutations/optIntoFeatureMutation.ts +++ b/packages/web/lib/networking/mutations/optIntoFeatureMutation.ts @@ -1,16 +1,17 @@ import { gql } from 'graphql-request' +import { Feature, featureFragment } from '../fragments/featureFragment' import { gqlFetcher } from '../networkHelpers' export interface OptInFeatureInput { name: string } -export interface OptInFeatureSuccess { - feature: { id: string } +export interface OptInFeatureResponse { + feature?: Feature } interface Response { - optInFeature: OptInFeatureSuccess + optInFeature: OptInFeatureResponse } export async function optInFeature( @@ -21,7 +22,7 @@ export async function optInFeature( optInFeature(input: $input) { ... on OptInFeatureSuccess { feature { - id + ...FeatureFields } } ... on OptInFeatureError { @@ -29,6 +30,7 @@ export async function optInFeature( } } } + ${featureFragment} ` try { const data = await gqlFetcher(mutation, { @@ -37,8 +39,8 @@ export async function optInFeature( const output = data as Response | undefined if ( !output || - !output.optInFeature || - 'errorCodes' in output?.optInFeature + !output.optInFeature.feature || + !output.optInFeature.feature.grantedAt ) { return false } diff --git a/packages/web/lib/networking/queries/useGetViewerQuery.tsx b/packages/web/lib/networking/queries/useGetViewerQuery.tsx index abf88c1ff..60649a8d5 100644 --- a/packages/web/lib/networking/queries/useGetViewerQuery.tsx +++ b/packages/web/lib/networking/queries/useGetViewerQuery.tsx @@ -1,5 +1,6 @@ import { gql } from 'graphql-request' import useSWR from 'swr' +import { Feature, featureFragment } from '../fragments/featureFragment' import { publicGqlFetcher } from '../networkHelpers' type ViewerQueryResponse = { @@ -22,6 +23,7 @@ export type UserBasicData = { source: string intercomHash: string features: string[] + featureList: Feature[] } export type UserProfile = { @@ -48,8 +50,12 @@ export function useGetViewerQuery(): ViewerQueryResponse { source intercomHash features + featureList { + ...FeatureFields + } } } + ${featureFragment} ` const { data, error, mutate } = useSWR(query, publicGqlFetcher) diff --git a/packages/web/pages/settings/features/beta.tsx b/packages/web/pages/settings/features/beta.tsx index 21ab7f17a..733333a86 100644 --- a/packages/web/pages/settings/features/beta.tsx +++ b/packages/web/pages/settings/features/beta.tsx @@ -6,6 +6,7 @@ import { HStack, VStack } from '../../../components/elements/LayoutPrimitives' import { StyledText } from '../../../components/elements/StyledText' import { SettingsLayout } from '../../../components/templates/SettingsLayout' import { styled } from '../../../components/tokens/stitches.config' +import { userHasFeature } from '../../../lib/featureFlag' import { optInFeature } from '../../../lib/networking/mutations/optIntoFeatureMutation' import { useGetViewerQuery } from '../../../lib/networking/queries/useGetViewerQuery' import { applyStoredTheme } from '../../../lib/themeUpdater' @@ -43,13 +44,13 @@ export default function Account(): JSX.Element { ) const hasYouTube = useMemo(() => { - return ( - (viewerData?.me?.features.indexOf('youtube-transcripts') ?? -1) !== -1 + return viewerData?.me?.featureList?.some( + (f) => f.name === 'youtube-transcripts' ) }, [viewerData]) const hasNotion = useMemo(() => { - return (viewerData?.me?.features.indexOf('notion') ?? -1) !== -1 + return viewerData?.me?.featureList?.some((f) => f.name === 'notion') }, [viewerData]) applyStoredTheme() @@ -89,7 +90,7 @@ export default function Account(): JSX.Element { Enabled beta features {!showSpinner ? ( <> - {viewerData?.me?.features.map((feature) => { + {viewerData?.me?.featureList.map((feature) => { return ( - {feature} + {`${feature.name}${ + userHasFeature(viewerData?.me, feature.name) + ? '' + : ' - Requested' + }`} ) })}