Only allow eligible users to enable digest

This commit is contained in:
Jackson Harper
2024-05-08 18:14:06 +08:00
parent a56562041d
commit e85bb9aa6a
8 changed files with 103 additions and 15 deletions

View File

@ -2042,6 +2042,7 @@ export type OptInFeatureError = {
export enum OptInFeatureErrorCode {
BadRequest = 'BAD_REQUEST',
Ineligible = 'INELIGIBLE',
NotFound = 'NOT_FOUND'
}

View File

@ -1527,6 +1527,7 @@ type OptInFeatureError {
enum OptInFeatureErrorCode {
BAD_REQUEST
INELIGIBLE
NOT_FOUND
}

View File

@ -6,6 +6,7 @@ import {
} from '../../generated/graphql'
import {
getFeatureName,
isOptInFeatureErrorCode,
optInFeature,
signFeatureToken,
} from '../../services/features'
@ -34,9 +35,9 @@ export const optInFeatureResolver = authorized<
}
const optedInFeature = await optInFeature(featureName, claims.uid)
if (!optedInFeature) {
if (isOptInFeatureErrorCode(optedInFeature)) {
return {
errorCodes: [OptInFeatureErrorCode.NotFound],
errorCodes: [optedInFeature],
}
}
log.info('Opted in to a feature', optedInFeature)

View File

@ -2139,6 +2139,7 @@ const schema = gql`
enum OptInFeatureErrorCode {
BAD_REQUEST
NOT_FOUND
INELIGIBLE
}
union RulesResult = RulesSuccess | RulesError

View File

@ -5,11 +5,14 @@ import { Feature } from '../entity/feature'
import { env } from '../env'
import { getRepository } from '../repository'
import { logger } from '../utils/logger'
import { OptInFeatureErrorCode } from '../generated/graphql'
import { Subscription, SubscriptionStatus } from '../entity/subscription'
import { libraryItemRepository } from '../repository/library_item'
const MAX_ULTRA_REALISTIC_USERS = 1500
const MAX_YOUTUBE_TRANSCRIPT_USERS = 500
const MAX_NOTION_USERS = 1000
const MAX_AIDIGEST_USERS = 10
const MAX_AIDIGEST_USERS = 1000
export enum FeatureName {
AISummaries = 'ai-summaries',
@ -20,6 +23,14 @@ export enum FeatureName {
AIExplain = 'ai-explain',
}
export function isOptInFeatureErrorCode(
value: Feature | OptInFeatureErrorCode
): value is OptInFeatureErrorCode {
return Object.values(OptInFeatureErrorCode).includes(
value as OptInFeatureErrorCode
)
}
export const getFeatureName = (name: string): FeatureName | undefined => {
return Object.values(FeatureName).find((v) => v === name)
}
@ -27,7 +38,7 @@ export const getFeatureName = (name: string): FeatureName | undefined => {
export const optInFeature = async (
name: FeatureName,
uid: string
): Promise<Feature | undefined> => {
): Promise<Feature | OptInFeatureErrorCode> => {
switch (name) {
case FeatureName.UltraRealisticVoice:
return optInLimitedFeature(
@ -44,9 +55,13 @@ export const optInFeature = async (
case FeatureName.Notion:
return optInLimitedFeature(FeatureName.Notion, uid, MAX_NOTION_USERS)
case FeatureName.AIDigest:
const eligible = await userDigestEligible(uid)
if (!eligible) {
return OptInFeatureErrorCode.Ineligible
}
return optInLimitedFeature(FeatureName.AIDigest, uid, MAX_AIDIGEST_USERS)
default:
return undefined
return OptInFeatureErrorCode.NotFound
}
}
@ -157,3 +172,13 @@ export const createFeature = async (feature: DeepPartial<Feature>) => {
export const createFeatures = async (features: DeepPartial<Feature>[]) => {
return getRepository(Feature).save(features)
}
export const userDigestEligible = async (uid: string): Promise<boolean> => {
const subscriptionsCount = await getRepository(Subscription).count({
where: { user: { id: uid }, status: SubscriptionStatus.Active },
})
const libraryItemsCount = await libraryItemRepository.count({
where: { user: { id: uid } },
})
return subscriptionsCount > 2 && libraryItemsCount > 10
}

View File

@ -2,12 +2,24 @@ import { gql } from 'graphql-request'
import { Feature, featureFragment } from '../fragments/featureFragment'
import { gqlFetcher } from '../networkHelpers'
export enum OptInFeatureErrorCode {
BadRequest = 'BAD_REQUEST',
Ineligible = 'INELIGIBLE',
NotFound = 'NOT_FOUND',
}
export type OptInResult = {
feature?: Feature
ineligible?: boolean
}
export interface OptInFeatureInput {
name: string
}
export interface OptInFeatureResponse {
interface OptInFeatureResponse {
feature?: Feature
errorCodes?: OptInFeatureErrorCode[]
}
interface Response {
@ -16,7 +28,7 @@ interface Response {
export async function optInFeature(
input: OptInFeatureInput
): Promise<boolean | undefined> {
): Promise<OptInResult> {
const mutation = gql`
mutation OptInFeature($input: OptInFeatureInput!) {
optInFeature(input: $input) {
@ -37,15 +49,29 @@ export async function optInFeature(
input,
})
const output = data as Response | undefined
console.log('output: ', output, output?.optInFeature?.errorCodes)
if (
output?.optInFeature?.errorCodes &&
output.optInFeature?.errorCodes?.indexOf(
OptInFeatureErrorCode.Ineligible
) !== -1
) {
return {
ineligible: true,
}
}
if (
!output ||
!output.optInFeature.feature ||
!output.optInFeature.feature.grantedAt
) {
return false
return {}
}
return {
feature: output.optInFeature.feature,
}
return true
} catch (err) {
return undefined
console.log('error opting into feature')
return {}
}
}

View File

@ -525,7 +525,8 @@ const BetaFeaturesSection = (): JSX.Element => {
}
const DigestSection = (): JSX.Element => {
const { viewerData, isLoading, mutate } = useGetViewerQuery()
const [optInError, setOptInError] = useState<string | undefined>(undefined)
const { viewerData, mutate } = useGetViewerQuery()
const [channelState, setChannelState] = useState({
push: false,
email: false,
@ -603,14 +604,24 @@ const DigestSection = (): JSX.Element => {
const requestDigestAccess = useCallback(() => {
;(async () => {
const result = await optInFeature({ name: 'ai-digest' })
if (!result) {
if (result.ineligible) {
setOptInError(
'To enable digest you need to have saved at least ten library items and have two active subscriptions.'
)
showErrorToast('You are not eligible for Digest')
} else if (!result.feature) {
showErrorToast('Error enabling digest')
return
} else {
setOptInError(undefined)
}
mutate()
})()
}, [])
const noChannelsSelected = useMemo(() => {
return !channelState.email && !channelState.library && !channelState.push
}, [channelState])
return (
<VStack
css={{
@ -652,6 +663,20 @@ const DigestSection = (): JSX.Element => {
</StyledText>
{hasDigest && (
<>
{noChannelsSelected && (
<StyledText
style="error"
css={{
display: 'flex',
gap: '5px',
lineHeight: '22px',
mt: '0px',
}}
>
You are opted into Omnivore Digest, please make sure to pick at
least one channel for your digest delivery.
</StyledText>
)}
<StyledText
style="footnote"
css={{ display: 'flex', gap: '5px', m: '0px' }}
@ -696,6 +721,14 @@ const DigestSection = (): JSX.Element => {
</StyledText>
</>
)}
{optInError && (
<StyledText
style="error"
css={{ display: 'flex', gap: '5px', m: '0px', mb: '5px' }}
>
{optInError}
</StyledText>
)}
{!hasDigest && (
<Button
style="ctaDarkYellow"

View File

@ -5,9 +5,9 @@ import { Box } from '../../components/elements/LayoutPrimitives'
export default function Extensions(): JSX.Element {
useEffect(() => {
if (window.innerWidth <= 1024) {
Router.push('/settings/installation/mobile')
Router.push('/settings/account')
} else {
Router.push('/settings/installation/extensions')
Router.push('/settings/account')
}
}, [])