Add optInFeature API

This commit is contained in:
Hongbo Wu
2022-11-09 17:35:54 +08:00
parent 98f59c50f0
commit 63de1c3359
9 changed files with 470 additions and 5 deletions

View File

@ -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

View File

@ -596,6 +596,17 @@ export type DeviceToken = {
token: Scalars['String'];
};
export type Feature = {
__typename?: 'Feature';
createdAt: Scalars['Date'];
expiresAt?: Maybe<Scalars['Date']>;
grantedAt?: Maybe<Scalars['Date']>;
id: Scalars['ID'];
name: Scalars['String'];
token: Scalars['String'];
updatedAt: Scalars['Date'];
};
export type FeedArticle = {
__typename?: 'FeedArticle';
annotationsCount?: Maybe<Scalars['Int']>;
@ -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<NewsletterEmail>;
};
export type OptInFeatureError = {
__typename?: 'OptInFeatureError';
errorCodes: Array<OptInFeatureErrorCode>;
};
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<Scalars['String']>;
@ -2684,6 +2722,7 @@ export type ResolversTypes = {
DeleteWebhookResult: ResolversTypes['DeleteWebhookError'] | ResolversTypes['DeleteWebhookSuccess'];
DeleteWebhookSuccess: ResolverTypeWrapper<DeleteWebhookSuccess>;
DeviceToken: ResolverTypeWrapper<DeviceToken>;
Feature: ResolverTypeWrapper<Feature>;
FeedArticle: ResolverTypeWrapper<FeedArticle>;
FeedArticleEdge: ResolverTypeWrapper<FeedArticleEdge>;
FeedArticlesError: ResolverTypeWrapper<FeedArticlesError>;
@ -2755,6 +2794,11 @@ export type ResolversTypes = {
NewsletterEmailsErrorCode: NewsletterEmailsErrorCode;
NewsletterEmailsResult: ResolversTypes['NewsletterEmailsError'] | ResolversTypes['NewsletterEmailsSuccess'];
NewsletterEmailsSuccess: ResolverTypeWrapper<NewsletterEmailsSuccess>;
OptInFeatureError: ResolverTypeWrapper<OptInFeatureError>;
OptInFeatureErrorCode: OptInFeatureErrorCode;
OptInFeatureInput: OptInFeatureInput;
OptInFeatureResult: ResolversTypes['OptInFeatureError'] | ResolversTypes['OptInFeatureSuccess'];
OptInFeatureSuccess: ResolverTypeWrapper<OptInFeatureSuccess>;
Page: ResolverTypeWrapper<Page>;
PageInfo: ResolverTypeWrapper<PageInfo>;
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<ContextType = ResolverContext, ParentType exten
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FeatureResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Feature'] = ResolversParentTypes['Feature']> = {
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
expiresAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
grantedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
token?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FeedArticleResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['FeedArticle'] = ResolversParentTypes['FeedArticle']> = {
annotationsCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
article?: Resolver<ResolversTypes['Article'], ParentType, ContextType>;
@ -3971,6 +4031,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
logOut?: Resolver<ResolversTypes['LogOutResult'], ParentType, ContextType>;
mergeHighlight?: Resolver<ResolversTypes['MergeHighlightResult'], ParentType, ContextType, RequireFields<MutationMergeHighlightArgs, 'input'>>;
moveLabel?: Resolver<ResolversTypes['MoveLabelResult'], ParentType, ContextType, RequireFields<MutationMoveLabelArgs, 'input'>>;
optInFeature?: Resolver<ResolversTypes['OptInFeatureResult'], ParentType, ContextType, RequireFields<MutationOptInFeatureArgs, 'input'>>;
reportItem?: Resolver<ResolversTypes['ReportItemResult'], ParentType, ContextType, RequireFields<MutationReportItemArgs, 'input'>>;
revokeApiKey?: Resolver<ResolversTypes['RevokeApiKeyResult'], ParentType, ContextType, RequireFields<MutationRevokeApiKeyArgs, 'id'>>;
saveArticleReadingProgress?: Resolver<ResolversTypes['SaveArticleReadingProgressResult'], ParentType, ContextType, RequireFields<MutationSaveArticleReadingProgressArgs, 'input'>>;
@ -4023,6 +4084,20 @@ export type NewsletterEmailsSuccessResolvers<ContextType = ResolverContext, Pare
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type OptInFeatureErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['OptInFeatureError'] = ResolversParentTypes['OptInFeatureError']> = {
errorCodes?: Resolver<Array<ResolversTypes['OptInFeatureErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type OptInFeatureResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['OptInFeatureResult'] = ResolversParentTypes['OptInFeatureResult']> = {
__resolveType: TypeResolveFn<'OptInFeatureError' | 'OptInFeatureSuccess', ParentType, ContextType>;
};
export type OptInFeatureSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['OptInFeatureSuccess'] = ResolversParentTypes['OptInFeatureSuccess']> = {
feature?: Resolver<ResolversTypes['Feature'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type PageResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Page'] = ResolversParentTypes['Page']> = {
author?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
@ -4835,6 +4910,7 @@ export type Resolvers<ContextType = ResolverContext> = {
DeleteWebhookResult?: DeleteWebhookResultResolvers<ContextType>;
DeleteWebhookSuccess?: DeleteWebhookSuccessResolvers<ContextType>;
DeviceToken?: DeviceTokenResolvers<ContextType>;
Feature?: FeatureResolvers<ContextType>;
FeedArticle?: FeedArticleResolvers<ContextType>;
FeedArticleEdge?: FeedArticleEdgeResolvers<ContextType>;
FeedArticlesError?: FeedArticlesErrorResolvers<ContextType>;
@ -4885,6 +4961,9 @@ export type Resolvers<ContextType = ResolverContext> = {
NewsletterEmailsError?: NewsletterEmailsErrorResolvers<ContextType>;
NewsletterEmailsResult?: NewsletterEmailsResultResolvers<ContextType>;
NewsletterEmailsSuccess?: NewsletterEmailsSuccessResolvers<ContextType>;
OptInFeatureError?: OptInFeatureErrorResolvers<ContextType>;
OptInFeatureResult?: OptInFeatureResultResolvers<ContextType>;
OptInFeatureSuccess?: OptInFeatureSuccessResolvers<ContextType>;
Page?: PageResolvers<ContextType>;
PageInfo?: PageInfoResolvers<ContextType>;
Profile?: ProfileResolvers<ContextType>;

View File

@ -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!

View File

@ -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],
}
}
})

View File

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

View File

@ -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

View File

@ -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<Feature | undefined> => {
if (name === FeatureName.UltraRealisticVoice) {
return optInUltraRealisticVoice(uid)
}
return undefined
}
const optInUltraRealisticVoice = async (uid: string): Promise<Feature> => {
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
)
}

View File

@ -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,
},
})
})
})
})
})

View File

@ -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)