Feature/subscription list resolver (#432)

* add subscriptions table

* add listSubscriptions schema

* add listSubscriptions resolver
This commit is contained in:
Hongbo Wu
2022-04-19 11:08:43 +08:00
committed by GitHub
parent 32dcf405a0
commit 1117a0c575
11 changed files with 400 additions and 14 deletions

View File

@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
import { SubscriptionStatus } from '../generated/graphql'
@Entity({ name: 'subscriptions' })
export class Subscription {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user!: User
@Column('text')
name!: string
@Column('enum', {
enum: SubscriptionStatus,
default: SubscriptionStatus.Active,
})
status!: SubscriptionStatus
@Column('text', { nullable: true })
description?: string
@Column('text', { nullable: true })
url?: string
@Column('text', { nullable: true })
unsubscribeMailTo?: string
@Column('text', { nullable: true })
unsubscribeHttpUrl?: string
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
}

View File

@ -11,6 +11,7 @@ import { MembershipTier, RegistrationType } from '../datalayer/user/model'
import { NewsletterEmail } from './newsletter_email'
import { Profile } from './profile'
import { Label } from './label'
import { Subscription } from './subscription'
@Entity()
export class User {
@ -49,4 +50,7 @@ export class User {
@OneToMany(() => Label, (label) => label.user)
labels?: Label[]
@OneToMany(() => Subscription, (subscription) => subscription.user)
subscriptions?: Subscription[]
}

View File

@ -1122,6 +1122,7 @@ export type Query = {
reminder: ReminderResult;
search: SearchResult;
sharedArticle: SharedArticleResult;
subscriptions: SubscriptionsResult;
user: UserResult;
users: UsersResult;
validateUsername: Scalars['Boolean'];
@ -1614,6 +1615,42 @@ export type SortParams = {
order?: InputMaybe<SortOrder>;
};
export type Subscription = {
__typename?: 'Subscription';
createdAt: Scalars['Date'];
description?: Maybe<Scalars['String']>;
id: Scalars['ID'];
name: Scalars['String'];
status: SubscriptionStatus;
unsubscribeHttpUrl?: Maybe<Scalars['String']>;
unsubscribeMailTo?: Maybe<Scalars['String']>;
updatedAt: Scalars['Date'];
url?: Maybe<Scalars['String']>;
};
export type SubscriptionsError = {
__typename?: 'SubscriptionsError';
errorCodes: Array<SubscriptionsErrorCode>;
};
export enum SubscriptionsErrorCode {
BadRequest = 'BAD_REQUEST',
Unauthorized = 'UNAUTHORIZED'
}
export type SubscriptionsResult = SubscriptionsError | SubscriptionsSuccess;
export type SubscriptionsSuccess = {
__typename?: 'SubscriptionsSuccess';
subscriptions: Array<Subscription>;
};
export enum SubscriptionStatus {
Active = 'ACTIVE',
Deleted = 'DELETED',
Unsubscribed = 'UNSUBSCRIBED'
}
export type UpdateHighlightError = {
__typename?: 'UpdateHighlightError';
errorCodes: Array<UpdateHighlightErrorCode>;
@ -2206,6 +2243,12 @@ export type ResolversTypes = {
SortOrder: SortOrder;
SortParams: SortParams;
String: ResolverTypeWrapper<Scalars['String']>;
Subscription: ResolverTypeWrapper<{}>;
SubscriptionsError: ResolverTypeWrapper<SubscriptionsError>;
SubscriptionsErrorCode: SubscriptionsErrorCode;
SubscriptionsResult: ResolversTypes['SubscriptionsError'] | ResolversTypes['SubscriptionsSuccess'];
SubscriptionsSuccess: ResolverTypeWrapper<SubscriptionsSuccess>;
SubscriptionStatus: SubscriptionStatus;
UpdateHighlightError: ResolverTypeWrapper<UpdateHighlightError>;
UpdateHighlightErrorCode: UpdateHighlightErrorCode;
UpdateHighlightInput: UpdateHighlightInput;
@ -2452,6 +2495,10 @@ export type ResolversParentTypes = {
SignupSuccess: SignupSuccess;
SortParams: SortParams;
String: Scalars['String'];
Subscription: {};
SubscriptionsError: SubscriptionsError;
SubscriptionsResult: ResolversParentTypes['SubscriptionsError'] | ResolversParentTypes['SubscriptionsSuccess'];
SubscriptionsSuccess: SubscriptionsSuccess;
UpdateHighlightError: UpdateHighlightError;
UpdateHighlightInput: UpdateHighlightInput;
UpdateHighlightReplyError: UpdateHighlightReplyError;
@ -3170,6 +3217,7 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
reminder?: Resolver<ResolversTypes['ReminderResult'], ParentType, ContextType, RequireFields<QueryReminderArgs, 'linkId'>>;
search?: Resolver<ResolversTypes['SearchResult'], ParentType, ContextType, Partial<QuerySearchArgs>>;
sharedArticle?: Resolver<ResolversTypes['SharedArticleResult'], ParentType, ContextType, RequireFields<QuerySharedArticleArgs, 'slug' | 'username'>>;
subscriptions?: Resolver<ResolversTypes['SubscriptionsResult'], ParentType, ContextType>;
user?: Resolver<ResolversTypes['UserResult'], ParentType, ContextType, Partial<QueryUserArgs>>;
users?: Resolver<ResolversTypes['UsersResult'], ParentType, ContextType>;
validateUsername?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<QueryValidateUsernameArgs, 'username'>>;
@ -3431,6 +3479,32 @@ export type SignupSuccessResolvers<ContextType = ResolverContext, ParentType ext
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SubscriptionResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Subscription'] = ResolversParentTypes['Subscription']> = {
createdAt?: SubscriptionResolver<ResolversTypes['Date'], "createdAt", ParentType, ContextType>;
description?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "description", ParentType, ContextType>;
id?: SubscriptionResolver<ResolversTypes['ID'], "id", ParentType, ContextType>;
name?: SubscriptionResolver<ResolversTypes['String'], "name", ParentType, ContextType>;
status?: SubscriptionResolver<ResolversTypes['SubscriptionStatus'], "status", ParentType, ContextType>;
unsubscribeHttpUrl?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "unsubscribeHttpUrl", ParentType, ContextType>;
unsubscribeMailTo?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "unsubscribeMailTo", ParentType, ContextType>;
updatedAt?: SubscriptionResolver<ResolversTypes['Date'], "updatedAt", ParentType, ContextType>;
url?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "url", ParentType, ContextType>;
};
export type SubscriptionsErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SubscriptionsError'] = ResolversParentTypes['SubscriptionsError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SubscriptionsErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SubscriptionsResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SubscriptionsResult'] = ResolversParentTypes['SubscriptionsResult']> = {
__resolveType: TypeResolveFn<'SubscriptionsError' | 'SubscriptionsSuccess', ParentType, ContextType>;
};
export type SubscriptionsSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SubscriptionsSuccess'] = ResolversParentTypes['SubscriptionsSuccess']> = {
subscriptions?: Resolver<Array<ResolversTypes['Subscription']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UpdateHighlightErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateHighlightError'] = ResolversParentTypes['UpdateHighlightError']> = {
errorCodes?: Resolver<Array<ResolversTypes['UpdateHighlightErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -3769,6 +3843,10 @@ export type Resolvers<ContextType = ResolverContext> = {
SignupError?: SignupErrorResolvers<ContextType>;
SignupResult?: SignupResultResolvers<ContextType>;
SignupSuccess?: SignupSuccessResolvers<ContextType>;
Subscription?: SubscriptionResolvers<ContextType>;
SubscriptionsError?: SubscriptionsErrorResolvers<ContextType>;
SubscriptionsResult?: SubscriptionsResultResolvers<ContextType>;
SubscriptionsSuccess?: SubscriptionsSuccessResolvers<ContextType>;
UpdateHighlightError?: UpdateHighlightErrorResolvers<ContextType>;
UpdateHighlightReplyError?: UpdateHighlightReplyErrorResolvers<ContextType>;
UpdateHighlightReplyResult?: UpdateHighlightReplyResultResolvers<ContextType>;

View File

@ -821,6 +821,7 @@ type Query {
reminder(linkId: ID!): ReminderResult!
search(after: String, first: Int, query: String): SearchResult!
sharedArticle(selectedHighlightId: String, slug: String!, username: String!): SharedArticleResult!
subscriptions: SubscriptionsResult!
user(userId: ID, username: String): UserResult!
users: UsersResult!
validateUsername(username: String!): Boolean!
@ -1212,6 +1213,39 @@ input SortParams {
order: SortOrder
}
type Subscription {
createdAt: Date!
description: String
id: ID!
name: String!
status: SubscriptionStatus!
unsubscribeHttpUrl: String
unsubscribeMailTo: String
updatedAt: Date!
url: String
}
type SubscriptionsError {
errorCodes: [SubscriptionsErrorCode!]!
}
enum SubscriptionsErrorCode {
BAD_REQUEST
UNAUTHORIZED
}
union SubscriptionsResult = SubscriptionsError | SubscriptionsSuccess
type SubscriptionsSuccess {
subscriptions: [Subscription!]!
}
enum SubscriptionStatus {
ACTIVE
DELETED
UNSUBSCRIBED
}
type UpdateHighlightError {
errorCodes: [UpdateHighlightErrorCode!]!
}

View File

@ -66,6 +66,7 @@ import {
setUserPersonalizationResolver,
signupResolver,
updateHighlightResolver,
updateLabelResolver,
updateLinkShareInfoResolver,
updateReminderResolver,
updateSharedCommentResolver,
@ -73,7 +74,6 @@ import {
updateUserResolver,
uploadFileRequestResolver,
validateUsernameResolver,
updateLabelResolver,
} from './index'
import { getShareInfoForArticle } from '../datalayer/links/share_info'
import {
@ -82,6 +82,7 @@ import {
} from '../utils/uploads'
import { getPageByParam } from '../elastic/pages'
import { generateApiKeyResolver } from './api_key'
import { subscriptionsResolver } from './subscriptions'
/* eslint-disable @typescript-eslint/naming-convention */
type ResultResolveType = {
@ -160,6 +161,7 @@ export const functionResolvers = {
reminder: reminderResolver,
labels: labelsResolver,
search: searchResolver,
subscriptions: subscriptionsResolver,
},
User: {
async sharedArticles(
@ -547,4 +549,5 @@ export const functionResolvers = {
...resultResolveTypeResolver('SetLabels'),
...resultResolveTypeResolver('GenerateApiKey'),
...resultResolveTypeResolver('Search'),
...resultResolveTypeResolver('Subscriptions'),
}

View File

@ -0,0 +1,54 @@
import { authorized } from '../../utils/helpers'
import {
SubscriptionsError,
SubscriptionsErrorCode,
SubscriptionsSuccess,
SubscriptionStatus,
} from '../../generated/graphql'
import { analytics } from '../../utils/analytics'
import { env } from '../../env'
import { getRepository } from '../../entity/utils'
import { User } from '../../entity/user'
export const subscriptionsResolver = authorized<
SubscriptionsSuccess,
SubscriptionsError
>(async (_obj, _params, { claims: { uid }, log }) => {
log.info('subscriptionsResolver')
analytics.track({
userId: uid,
event: 'subscriptions',
properties: {
env: env.server.apiEnv,
},
})
try {
const user = await getRepository(User).findOne({
where: { id: uid, subscriptions: { status: SubscriptionStatus.Active } },
relations: {
subscriptions: true,
},
order: {
subscriptions: {
createdAt: 'DESC',
},
},
})
if (!user) {
return {
errorCodes: [SubscriptionsErrorCode.Unauthorized],
}
}
return {
subscriptions: user.subscriptions || [],
}
} catch (error) {
log.error(error)
return {
errorCodes: [SubscriptionsErrorCode.BadRequest],
}
}
})

View File

@ -1444,6 +1444,39 @@ const schema = gql`
errorCodes: [SearchErrorCode!]!
}
union SubscriptionsResult = SubscriptionsSuccess | SubscriptionsError
type SubscriptionsSuccess {
subscriptions: [Subscription!]!
}
type Subscription {
id: ID!
name: String!
url: String
description: String
status: SubscriptionStatus!
unsubscribeMailTo: String
unsubscribeHttpUrl: String
createdAt: Date!
updatedAt: Date!
}
enum SubscriptionStatus {
ACTIVE
UNSUBSCRIBED
DELETED
}
type SubscriptionsError {
errorCodes: [SubscriptionsErrorCode!]!
}
enum SubscriptionsErrorCode {
UNAUTHORIZED
BAD_REQUEST
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -1542,6 +1575,7 @@ const schema = gql`
reminder(linkId: ID!): ReminderResult!
labels: LabelsResult!
search(after: String, first: Int, query: String): SearchResult!
subscriptions: SubscriptionsResult!
}
`

View File

@ -1,16 +1,18 @@
import Postgrator from "postgrator";
import { User } from "../src/entity/user";
import { Profile } from "../src/entity/profile";
import { Page } from "../src/entity/page";
import { Link } from "../src/entity/link";
import { Reminder } from "../src/entity/reminder";
import { NewsletterEmail } from "../src/entity/newsletter_email";
import { UserDeviceToken } from "../src/entity/user_device_tokens";
import { Label } from "../src/entity/label";
import { AppDataSource } from "../src/server";
import { getRepository } from "../src/entity/utils";
import { createUser } from "../src/services/create_user";
import { SnakeNamingStrategy } from "typeorm-naming-strategies";
import Postgrator from 'postgrator'
import { User } from '../src/entity/user'
import { Profile } from '../src/entity/profile'
import { Page } from '../src/entity/page'
import { Link } from '../src/entity/link'
import { Reminder } from '../src/entity/reminder'
import { NewsletterEmail } from '../src/entity/newsletter_email'
import { UserDeviceToken } from '../src/entity/user_device_tokens'
import { Label } from '../src/entity/label'
import { Subscription } from '../src/entity/subscription'
import { AppDataSource } from '../src/server'
import { getRepository } from '../src/entity/utils'
import { createUser } from '../src/services/create_user'
import { SnakeNamingStrategy } from 'typeorm-naming-strategies'
import { SubscriptionStatus } from '../src/generated/graphql'
const runMigrations = async () => {
const migrationDirectory = __dirname + '/../../db/migrations'
@ -187,3 +189,14 @@ export const createTestLabel = async (
color: color,
})
}
export const createTestSubscription = async (
user: User,
name: string
): Promise<Subscription> => {
return getRepository(Subscription).save({
user,
name,
status: SubscriptionStatus.Active,
})
}

View File

@ -0,0 +1,81 @@
import { createTestSubscription, createTestUser, deleteTestUser } from '../db'
import { graphqlRequest, request } from '../util'
import { Subscription } from '../../src/entity/subscription'
import { expect } from 'chai'
import 'mocha'
import { User } from '../../src/entity/user'
describe('Subscriptions API', () => {
const username = 'fakeUser'
let user: User
let authToken: string
let subscriptions: Subscription[]
before(async () => {
// create test user and login
user = await createTestUser(username)
const res = await request
.post('/local/debug/fake-user-login')
.send({ fakeEmail: user.email })
authToken = res.body.authToken
// create testing subscriptions
const sub1 = await createTestSubscription(user, 'sub_1')
const sub2 = await createTestSubscription(user, 'sub_2')
subscriptions = [sub2, sub1]
})
after(async () => {
// clean up
await deleteTestUser(username)
})
describe('GET subscriptions', () => {
let query: string
beforeEach(() => {
query = `
query {
subscriptions {
... on SubscriptionsSuccess {
subscriptions {
id
name
}
}
... on SubscriptionsError {
errorCodes
}
}
}
`
})
it('should return subscriptions', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.subscriptions.subscriptions).to.eql(
subscriptions.map((sub) => ({
id: sub.id,
name: sub.name,
}))
)
})
it('responds status code 400 when invalid query', async () => {
const invalidQuery = `
query {
subscriptions {}
}
`
return graphqlRequest(invalidQuery, authToken).expect(400)
})
it('responds status code 500 when invalid user', async () => {
const invalidAuthToken = 'Fake token'
return graphqlRequest(query, invalidAuthToken).expect(500)
})
})
})

View File

@ -0,0 +1,27 @@
-- Type: DO
-- Name: add_subscriptions_table
-- Description: Add subscriptions table
BEGIN;
CREATE TYPE subscription_status_type AS ENUM ('ACTIVE', 'UNSUBSCRIBED', 'DELETED');
CREATE TABLE omnivore.subscriptions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(),
user_id uuid NOT NULL REFERENCES omnivore.user (id) ON DELETE CASCADE,
name text NOT NULL,
description text,
url text,
status subscription_status_type NOT NULL,
unsubscribe_mail_to text,
unsubscribe_http_url text,
created_at timestamptz NOT NULL DEFAULT current_timestamp,
updated_at timestamptz NOT NULL DEFAULT current_timestamp
);
CREATE TRIGGER update_subscription_modtime BEFORE UPDATE ON omnivore.subscriptions
FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
GRANT SELECT, INSERT, UPDATE ON omnivore.subscriptions TO omnivore_user;
COMMIT;

View File

@ -0,0 +1,10 @@
-- Type: UNDO
-- Name: add_subscriptions_table
-- Description: Add subscriptions table
BEGIN;
DROP TABLE omnivore.subscriptions;
DROP TYPE subscription_status_type;
COMMIT;