diff --git a/packages/api/src/entity/feature.ts b/packages/api/src/entity/feature.ts index 885a1d503..77ca572e7 100644 --- a/packages/api/src/entity/feature.ts +++ b/packages/api/src/entity/feature.ts @@ -25,7 +25,7 @@ export class Feature { grantedAt?: Date | null @Column('timestamp', { nullable: true }) - expiredAt?: Date | null + expiresAt?: Date | null @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 88a340b40..1f1815b31 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -596,6 +596,17 @@ export type DeviceToken = { token: Scalars['String']; }; +export type Feature = { + __typename?: 'Feature'; + createdAt: Scalars['Date']; + expiresAt?: Maybe; + grantedAt?: Maybe; + id: Scalars['ID']; + name: Scalars['String']; + token: Scalars['String']; + updatedAt: Scalars['Date']; +}; + export type FeedArticle = { __typename?: 'FeedArticle'; annotationsCount?: Maybe; @@ -971,6 +982,7 @@ export type Mutation = { logOut: LogOutResult; mergeHighlight: MergeHighlightResult; moveLabel: MoveLabelResult; + optInFeature: OptInFeatureResult; reportItem: ReportItemResult; revokeApiKey: RevokeApiKeyResult; saveArticleReadingProgress: SaveArticleReadingProgressResult; @@ -1113,6 +1125,11 @@ export type MutationMoveLabelArgs = { }; +export type MutationOptInFeatureArgs = { + input: OptInFeatureInput; +}; + + export type MutationReportItemArgs = { input: ReportItemInput; }; @@ -1281,6 +1298,27 @@ export type NewsletterEmailsSuccess = { newsletterEmails: Array; }; +export type OptInFeatureError = { + __typename?: 'OptInFeatureError'; + errorCodes: Array; +}; + +export enum OptInFeatureErrorCode { + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND' +} + +export type OptInFeatureInput = { + name: Scalars['String']; +}; + +export type OptInFeatureResult = OptInFeatureError | OptInFeatureSuccess; + +export type OptInFeatureSuccess = { + __typename?: 'OptInFeatureSuccess'; + feature: Feature; +}; + export type Page = { __typename?: 'Page'; author?: Maybe; @@ -2684,6 +2722,7 @@ export type ResolversTypes = { DeleteWebhookResult: ResolversTypes['DeleteWebhookError'] | ResolversTypes['DeleteWebhookSuccess']; DeleteWebhookSuccess: ResolverTypeWrapper; DeviceToken: ResolverTypeWrapper; + Feature: ResolverTypeWrapper; FeedArticle: ResolverTypeWrapper; FeedArticleEdge: ResolverTypeWrapper; FeedArticlesError: ResolverTypeWrapper; @@ -2755,6 +2794,11 @@ export type ResolversTypes = { NewsletterEmailsErrorCode: NewsletterEmailsErrorCode; NewsletterEmailsResult: ResolversTypes['NewsletterEmailsError'] | ResolversTypes['NewsletterEmailsSuccess']; NewsletterEmailsSuccess: ResolverTypeWrapper; + OptInFeatureError: ResolverTypeWrapper; + OptInFeatureErrorCode: OptInFeatureErrorCode; + OptInFeatureInput: OptInFeatureInput; + OptInFeatureResult: ResolversTypes['OptInFeatureError'] | ResolversTypes['OptInFeatureSuccess']; + OptInFeatureSuccess: ResolverTypeWrapper; Page: ResolverTypeWrapper; PageInfo: ResolverTypeWrapper; PageInfoInput: PageInfoInput; @@ -3045,6 +3089,7 @@ export type ResolversParentTypes = { DeleteWebhookResult: ResolversParentTypes['DeleteWebhookError'] | ResolversParentTypes['DeleteWebhookSuccess']; DeleteWebhookSuccess: DeleteWebhookSuccess; DeviceToken: DeviceToken; + Feature: Feature; FeedArticle: FeedArticle; FeedArticleEdge: FeedArticleEdge; FeedArticlesError: FeedArticlesError; @@ -3103,6 +3148,10 @@ export type ResolversParentTypes = { NewsletterEmailsError: NewsletterEmailsError; NewsletterEmailsResult: ResolversParentTypes['NewsletterEmailsError'] | ResolversParentTypes['NewsletterEmailsSuccess']; NewsletterEmailsSuccess: NewsletterEmailsSuccess; + OptInFeatureError: OptInFeatureError; + OptInFeatureInput: OptInFeatureInput; + OptInFeatureResult: ResolversParentTypes['OptInFeatureError'] | ResolversParentTypes['OptInFeatureSuccess']; + OptInFeatureSuccess: OptInFeatureSuccess; Page: Page; PageInfo: PageInfo; PageInfoInput: PageInfoInput; @@ -3677,6 +3726,17 @@ export type DeviceTokenResolvers; }; +export type FeatureResolvers = { + createdAt?: Resolver; + expiresAt?: Resolver, ParentType, ContextType>; + grantedAt?: Resolver, ParentType, ContextType>; + id?: Resolver; + name?: Resolver; + token?: Resolver; + updatedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type FeedArticleResolvers = { annotationsCount?: Resolver, ParentType, ContextType>; article?: Resolver; @@ -3971,6 +4031,7 @@ export type MutationResolvers; mergeHighlight?: Resolver>; moveLabel?: Resolver>; + optInFeature?: Resolver>; reportItem?: Resolver>; revokeApiKey?: Resolver>; saveArticleReadingProgress?: Resolver>; @@ -4023,6 +4084,20 @@ export type NewsletterEmailsSuccessResolvers; }; +export type OptInFeatureErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type OptInFeatureResultResolvers = { + __resolveType: TypeResolveFn<'OptInFeatureError' | 'OptInFeatureSuccess', ParentType, ContextType>; +}; + +export type OptInFeatureSuccessResolvers = { + feature?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type PageResolvers = { author?: Resolver, ParentType, ContextType>; createdAt?: Resolver; @@ -4835,6 +4910,7 @@ export type Resolvers = { DeleteWebhookResult?: DeleteWebhookResultResolvers; DeleteWebhookSuccess?: DeleteWebhookSuccessResolvers; DeviceToken?: DeviceTokenResolvers; + Feature?: FeatureResolvers; FeedArticle?: FeedArticleResolvers; FeedArticleEdge?: FeedArticleEdgeResolvers; FeedArticlesError?: FeedArticlesErrorResolvers; @@ -4885,6 +4961,9 @@ export type Resolvers = { NewsletterEmailsError?: NewsletterEmailsErrorResolvers; NewsletterEmailsResult?: NewsletterEmailsResultResolvers; NewsletterEmailsSuccess?: NewsletterEmailsSuccessResolvers; + OptInFeatureError?: OptInFeatureErrorResolvers; + OptInFeatureResult?: OptInFeatureResultResolvers; + OptInFeatureSuccess?: OptInFeatureSuccessResolvers; Page?: PageResolvers; PageInfo?: PageInfoResolvers; Profile?: ProfileResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index fce0f2f7a..4a4048d46 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -524,6 +524,16 @@ type DeviceToken { token: String! } +type Feature { + createdAt: Date! + expiresAt: Date + grantedAt: Date + id: ID! + name: String! + token: String! + updatedAt: Date! +} + type FeedArticle { annotationsCount: Int article: Article! @@ -865,6 +875,7 @@ type Mutation { logOut: LogOutResult! mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult! moveLabel(input: MoveLabelInput!): MoveLabelResult! + optInFeature(input: OptInFeatureInput!): OptInFeatureResult! reportItem(input: ReportItemInput!): ReportItemResult! revokeApiKey(id: ID!): RevokeApiKeyResult! saveArticleReadingProgress(input: SaveArticleReadingProgressInput!): SaveArticleReadingProgressResult! @@ -917,6 +928,25 @@ type NewsletterEmailsSuccess { newsletterEmails: [NewsletterEmail!]! } +type OptInFeatureError { + errorCodes: [OptInFeatureErrorCode!]! +} + +enum OptInFeatureErrorCode { + BAD_REQUEST + NOT_FOUND +} + +input OptInFeatureInput { + name: String! +} + +union OptInFeatureResult = OptInFeatureError | OptInFeatureSuccess + +type OptInFeatureSuccess { + feature: Feature! +} + type Page { author: String createdAt: Date! diff --git a/packages/api/src/resolvers/features/index.ts b/packages/api/src/resolvers/features/index.ts new file mode 100644 index 000000000..cb879d71f --- /dev/null +++ b/packages/api/src/resolvers/features/index.ts @@ -0,0 +1,60 @@ +import { authorized } from '../../utils/helpers' +import { + MutationOptInFeatureArgs, + OptInFeatureError, + OptInFeatureErrorCode, + OptInFeatureSuccess, +} from '../../generated/graphql' +import { + getFeatureName, + optInFeature, + signFeatureToken, +} from '../../services/features' + +export const optInFeatureResolver = authorized< + OptInFeatureSuccess, + OptInFeatureError, + MutationOptInFeatureArgs +>(async (_, { input: { name } }, { claims, log }) => { + log.info('Opting in to a feature', { + feature: name, + labels: { + source: 'resolver', + resolver: 'optInFeatureResolver', + uid: claims.uid, + }, + }) + + try { + const featureName = getFeatureName(name) + if (!featureName) { + return { + errorCodes: [OptInFeatureErrorCode.NotFound], + } + } + + const optIn = await optInFeature(featureName, claims.uid) + if (!optIn) { + return { + errorCodes: [OptInFeatureErrorCode.NotFound], + } + } + + const token = signFeatureToken(optIn) + + return { + feature: { + ...optIn, + token, + }, + } + } catch (e) { + log.error('Error opting in to a feature', { + error: e, + }) + + return { + errorCodes: [OptInFeatureErrorCode.BadRequest], + } + } +}) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 50cd8580b..8bb5cfb7e 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -5,8 +5,8 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { createReactionResolver, deleteReactionResolver } from './reaction' import { Claims, WithDataSourcesContext } from './types' -import { createImageProxyUrl } from './../utils/imageproxy' -import { userDataToUser, validatedDate } from './../utils/helpers' +import { createImageProxyUrl } from '../utils/imageproxy' +import { userDataToUser, validatedDate } from '../utils/helpers' import { Article, @@ -18,7 +18,7 @@ import { Reaction, SearchItem, User, -} from './../generated/graphql' +} from '../generated/graphql' import { addPopularReadResolver, @@ -101,6 +101,7 @@ import { } from '../utils/uploads' import { getPageByParam } from '../elastic/pages' import { recentSearchesResolver } from './recent_searches' +import { optInFeatureResolver } from './features' /* eslint-disable @typescript-eslint/naming-convention */ type ResultResolveType = { @@ -171,6 +172,7 @@ export const functionResolvers = { moveLabel: moveLabelResolver, setIntegration: setIntegrationResolver, deleteIntegration: deleteIntegrationResolver, + optInFeature: optInFeatureResolver, }, Query: { me: getMeUserResolver, @@ -607,4 +609,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('Integrations'), ...resultResolveTypeResolver('DeleteIntegration'), ...resultResolveTypeResolver('RecentSearches'), + ...resultResolveTypeResolver('OptInFeature'), } diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 765379a09..7faa4c4a7 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1912,6 +1912,35 @@ const schema = gql` BAD_REQUEST } + input OptInFeatureInput { + name: String! + } + + union OptInFeatureResult = OptInFeatureSuccess | OptInFeatureError + + type OptInFeatureSuccess { + feature: Feature! + } + + type Feature { + id: ID! + name: String! + token: String! + createdAt: Date! + updatedAt: Date! + grantedAt: Date + expiresAt: Date + } + + type OptInFeatureError { + errorCodes: [OptInFeatureErrorCode!]! + } + + enum OptInFeatureErrorCode { + BAD_REQUEST + NOT_FOUND + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -1983,6 +2012,7 @@ const schema = gql` moveLabel(input: MoveLabelInput!): MoveLabelResult! setIntegration(input: SetIntegrationInput!): SetIntegrationResult! deleteIntegration(id: ID!): DeleteIntegrationResult! + optInFeature(input: OptInFeatureInput!): OptInFeatureResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed diff --git a/packages/api/src/services/features.ts b/packages/api/src/services/features.ts new file mode 100644 index 000000000..9afe0e2de --- /dev/null +++ b/packages/api/src/services/features.ts @@ -0,0 +1,67 @@ +import { Feature } from '../entity/feature' +import { getRepository } from '../entity/utils' +import * as jwt from 'jsonwebtoken' +import { env } from '../env' +import { IsNull, Not } from 'typeorm' + +enum FeatureName { + UltraRealisticVoice = 'ultra-realistic-voice', +} + +export const getFeatureName = (name: string): FeatureName | undefined => { + return Object.values(FeatureName).find((v) => v === name) +} + +export const optInFeature = async ( + name: FeatureName, + uid: string +): Promise => { + if (name === FeatureName.UltraRealisticVoice) { + return optInUltraRealisticVoice(uid) + } + + return undefined +} + +const optInUltraRealisticVoice = async (uid: string): Promise => { + const feature = await getRepository(Feature).findOneBy({ + user: { id: uid }, + name: FeatureName.UltraRealisticVoice, + }) + if (feature) { + // already opted in + console.log('already opted in') + return feature + } + + // opt in to feature for the first 1000 users + const count = await getRepository(Feature).countBy({ + name: FeatureName.UltraRealisticVoice, + grantedAt: Not(IsNull()), + }) + + let grantedAt: Date | null = new Date() + if (count >= 1000) { + console.log('feature limit reached') + grantedAt = null + } + + return getRepository(Feature).save({ + user: { id: uid }, + name: FeatureName.UltraRealisticVoice, + grantedAt, + }) +} + +export const signFeatureToken = (feature: Feature): string => { + return jwt.sign( + { + userid: feature.user.id, + feature_name: feature.name, + createdat: feature.createdAt.getTime(), + expiresat: feature.expiresAt?.getTime(), + grantedat: feature.grantedAt?.getTime(), + }, + env.server.jwtSecret + ) +} diff --git a/packages/api/test/resolvers/features.test.ts b/packages/api/test/resolvers/features.test.ts new file mode 100644 index 000000000..49250d074 --- /dev/null +++ b/packages/api/test/resolvers/features.test.ts @@ -0,0 +1,196 @@ +import 'mocha' +import { expect } from 'chai' +import { User } from '../../src/entity/user' +import { createTestUser, deleteTestUser } from '../db' +import { graphqlRequest, request } from '../util' +import { getRepository } from '../../src/entity/utils' +import { Feature } from '../../src/entity/feature' +import * as jwt from 'jsonwebtoken' +import sinon, { SinonFakeTimers } from 'sinon' +import { env } from '../../src/env' +import { Like } from 'typeorm' + +describe('features resolvers', () => { + let loginUser: User + let authToken: string + + before(async () => { + // create test user and login + loginUser = await createTestUser('loginUser') + const res = await request + .post('/local/debug/fake-user-login') + .send({ fakeEmail: loginUser.email }) + + authToken = res.body.authToken + }) + + after(async () => { + await deleteTestUser(loginUser.name) + }) + + describe('optInFeature API', () => { + const feature = 'ultra-realistic-voice' + const now = new Date() + let clock: SinonFakeTimers + + const query = (name: string) => ` + mutation { + optInFeature(input: { + name: "${name}" + }) { + ... on OptInFeatureSuccess { + feature { + name + grantedAt + token + } + } + ... on OptInFeatureError { + errorCodes + } + } + } + ` + + beforeEach(() => { + // mock date + clock = sinon.useFakeTimers(now.getTime()) + }) + + afterEach(() => { + clock.restore() + }) + + context('when user is the first 1000 users', () => { + after(async () => { + // reset feature + await getRepository(Feature).delete({ + user: { id: loginUser.id }, + }) + }) + + it('opts in to the feature', async () => { + const res = await graphqlRequest(query(feature), authToken).expect(200) + + const token = jwt.sign( + { + userid: loginUser.id, + feature_name: feature, + createdat: now.getTime(), + grantedat: now.getTime(), + }, + env.server.jwtSecret + ) + + expect(res.body.data.optInFeature).to.eql({ + feature: { + name: feature, + // set milliseconds to 000 + grantedAt: now.toISOString().replace(/\.\d{3}Z$/, '.000Z'), + token, + }, + }) + }) + }) + + context('when user is not the first 1000 users', () => { + before(async () => { + // create 1000 opt-in users + const usersToSave = Array.from(Array(1000).keys()).map((i) => { + return { + name: `user${i}`, + source: 'GOOGLE', + sourceUserId: `fake-user-id-user${i}`, + email: `user${i}@omnivore.app`, + username: `user${i}`, + bio: `i am user${i}`, + } + }) + + const users = await getRepository(User).save(usersToSave) + + const features = users.map((user) => { + return { + user: { id: user.id }, + name: feature, + grantedAt: now, + } + }) + + await getRepository(Feature).save(features) + }) + + after(async () => { + // reset opt-in users + await getRepository(User).delete({ + name: Like(`user%`), + }) + await getRepository(Feature).delete({ + name: feature, + }) + }) + + it('does not opt in to the feature', async () => { + const res = await graphqlRequest(query(feature), authToken).expect(200) + + const token = jwt.sign( + { + userid: loginUser.id, + feature_name: feature, + createdat: now.getTime(), + grantedat: now.getTime(), + }, + env.server.jwtSecret + ) + + expect(res.body.data.optInFeature).to.eql({ + feature: { + name: feature, + grantedAt: null, + token, + }, + }) + }) + }) + + context('when user is already opted in', () => { + before(async () => { + // opt in + await getRepository(Feature).save({ + user: { id: loginUser.id }, + name: feature, + grantedAt: new Date(), + }) + }) + + after(async () => { + // reset feature + await getRepository(Feature).delete({ + user: { id: loginUser.id }, + }) + }) + + it('returns the feature', async () => { + const res = await graphqlRequest(query(feature), authToken).expect(200) + + const token = jwt.sign( + { + userid: loginUser.id, + feature_name: feature, + createdat: now.getTime(), + grantedat: now.getTime(), + }, + env.server.jwtSecret + ) + + expect(res.body.data.optInFeature).to.eql({ + feature: { + name: feature, + grantedAt: now.toISOString().replace(/\.\d{3}Z$/, '.000Z'), + token, + }, + }) + }) + }) + }) +}) diff --git a/packages/db/migrations/0098.do.create_features_table.sql b/packages/db/migrations/0098.do.create_features_table.sql index 600145299..9a5be9b24 100755 --- a/packages/db/migrations/0098.do.create_features_table.sql +++ b/packages/db/migrations/0098.do.create_features_table.sql @@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS omnivore.features ( user_id uuid NOT NULL REFERENCES omnivore.user ON DELETE CASCADE, name text NOT NULL, granted_at timestamptz, - expired_at timestamptz, + expires_at timestamptz, created_at timestamptz NOT NULL DEFAULT current_timestamp, updated_at timestamptz NOT NULL DEFAULT current_timestamp, UNIQUE (user_id, name)