diff --git a/packages/api/src/entity/subscription.ts b/packages/api/src/entity/subscription.ts new file mode 100644 index 000000000..d00d19eba --- /dev/null +++ b/packages/api/src/entity/subscription.ts @@ -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 +} diff --git a/packages/api/src/entity/user.ts b/packages/api/src/entity/user.ts index 1b7a55da3..320d26885 100644 --- a/packages/api/src/entity/user.ts +++ b/packages/api/src/entity/user.ts @@ -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[] } diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index d1e733acc..7b11aece0 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -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; }; +export type Subscription = { + __typename?: 'Subscription'; + createdAt: Scalars['Date']; + description?: Maybe; + id: Scalars['ID']; + name: Scalars['String']; + status: SubscriptionStatus; + unsubscribeHttpUrl?: Maybe; + unsubscribeMailTo?: Maybe; + updatedAt: Scalars['Date']; + url?: Maybe; +}; + +export type SubscriptionsError = { + __typename?: 'SubscriptionsError'; + errorCodes: Array; +}; + +export enum SubscriptionsErrorCode { + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED' +} + +export type SubscriptionsResult = SubscriptionsError | SubscriptionsSuccess; + +export type SubscriptionsSuccess = { + __typename?: 'SubscriptionsSuccess'; + subscriptions: Array; +}; + +export enum SubscriptionStatus { + Active = 'ACTIVE', + Deleted = 'DELETED', + Unsubscribed = 'UNSUBSCRIBED' +} + export type UpdateHighlightError = { __typename?: 'UpdateHighlightError'; errorCodes: Array; @@ -2206,6 +2243,12 @@ export type ResolversTypes = { SortOrder: SortOrder; SortParams: SortParams; String: ResolverTypeWrapper; + Subscription: ResolverTypeWrapper<{}>; + SubscriptionsError: ResolverTypeWrapper; + SubscriptionsErrorCode: SubscriptionsErrorCode; + SubscriptionsResult: ResolversTypes['SubscriptionsError'] | ResolversTypes['SubscriptionsSuccess']; + SubscriptionsSuccess: ResolverTypeWrapper; + SubscriptionStatus: SubscriptionStatus; UpdateHighlightError: ResolverTypeWrapper; 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>; search?: Resolver>; sharedArticle?: Resolver>; + subscriptions?: Resolver; user?: Resolver>; users?: Resolver; validateUsername?: Resolver>; @@ -3431,6 +3479,32 @@ export type SignupSuccessResolvers; }; +export type SubscriptionResolvers = { + createdAt?: SubscriptionResolver; + description?: SubscriptionResolver, "description", ParentType, ContextType>; + id?: SubscriptionResolver; + name?: SubscriptionResolver; + status?: SubscriptionResolver; + unsubscribeHttpUrl?: SubscriptionResolver, "unsubscribeHttpUrl", ParentType, ContextType>; + unsubscribeMailTo?: SubscriptionResolver, "unsubscribeMailTo", ParentType, ContextType>; + updatedAt?: SubscriptionResolver; + url?: SubscriptionResolver, "url", ParentType, ContextType>; +}; + +export type SubscriptionsErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type SubscriptionsResultResolvers = { + __resolveType: TypeResolveFn<'SubscriptionsError' | 'SubscriptionsSuccess', ParentType, ContextType>; +}; + +export type SubscriptionsSuccessResolvers = { + subscriptions?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type UpdateHighlightErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -3769,6 +3843,10 @@ export type Resolvers = { SignupError?: SignupErrorResolvers; SignupResult?: SignupResultResolvers; SignupSuccess?: SignupSuccessResolvers; + Subscription?: SubscriptionResolvers; + SubscriptionsError?: SubscriptionsErrorResolvers; + SubscriptionsResult?: SubscriptionsResultResolvers; + SubscriptionsSuccess?: SubscriptionsSuccessResolvers; UpdateHighlightError?: UpdateHighlightErrorResolvers; UpdateHighlightReplyError?: UpdateHighlightReplyErrorResolvers; UpdateHighlightReplyResult?: UpdateHighlightReplyResultResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 2f77183e1..12e6d0fce 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -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!]! } diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 7a99e06c0..1897ea782 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -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'), } diff --git a/packages/api/src/resolvers/subscriptions/index.ts b/packages/api/src/resolvers/subscriptions/index.ts new file mode 100644 index 000000000..a8153e48f --- /dev/null +++ b/packages/api/src/resolvers/subscriptions/index.ts @@ -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], + } + } +}) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 08def9c86..c8286b7a6 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -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! } ` diff --git a/packages/api/test/db.ts b/packages/api/test/db.ts index d7b75c4c4..944535721 100644 --- a/packages/api/test/db.ts +++ b/packages/api/test/db.ts @@ -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 => { + return getRepository(Subscription).save({ + user, + name, + status: SubscriptionStatus.Active, + }) +} diff --git a/packages/api/test/resolvers/subscriptions.test.ts b/packages/api/test/resolvers/subscriptions.test.ts new file mode 100644 index 000000000..a79fb0678 --- /dev/null +++ b/packages/api/test/resolvers/subscriptions.test.ts @@ -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) + }) + }) +}) diff --git a/packages/db/migrations/0080.do.add_subscriptions_table.sql b/packages/db/migrations/0080.do.add_subscriptions_table.sql new file mode 100755 index 000000000..faa3a7028 --- /dev/null +++ b/packages/db/migrations/0080.do.add_subscriptions_table.sql @@ -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; diff --git a/packages/db/migrations/0080.undo.add_subscriptions_table.sql b/packages/db/migrations/0080.undo.add_subscriptions_table.sql new file mode 100755 index 000000000..a434c103c --- /dev/null +++ b/packages/db/migrations/0080.undo.add_subscriptions_table.sql @@ -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;