From 966295385aed2dad36c89b376231596e037db4e0 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 21 Feb 2022 16:03:00 +0800 Subject: [PATCH 01/20] add sql --- .../0070.do.update_labels_table.sql | 19 +++++++++++++++++++ .../0070.undo.update_labels_table.sql | 15 +++++++++++++++ 2 files changed, 34 insertions(+) create mode 100755 packages/db/migrations/0070.do.update_labels_table.sql create mode 100755 packages/db/migrations/0070.undo.update_labels_table.sql diff --git a/packages/db/migrations/0070.do.update_labels_table.sql b/packages/db/migrations/0070.do.update_labels_table.sql new file mode 100755 index 000000000..617376715 --- /dev/null +++ b/packages/db/migrations/0070.do.update_labels_table.sql @@ -0,0 +1,19 @@ +-- Type: DO +-- Name: update_labels_table +-- Description: Update labels table and create link_labels table + +BEGIN; + +ALTER TABLE omnivore.labels + DROP COLUMN link_id, + ADD COLUMN color text, + ADD COLUMN description text, + ADD CONSTRAINT label_name_unique UNIQUE (user_id, name); + +CREATE TABLE omnivore.link_labels ( + link_id uuid NOT NULL REFERENCES omnivore.links ON DELETE CASCADE, + label_id uuid NOT NULL REFERENCES omnivore.labels ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT current_timestamp +); + +COMMIT; diff --git a/packages/db/migrations/0070.undo.update_labels_table.sql b/packages/db/migrations/0070.undo.update_labels_table.sql new file mode 100755 index 000000000..8285b5197 --- /dev/null +++ b/packages/db/migrations/0070.undo.update_labels_table.sql @@ -0,0 +1,15 @@ +-- Type: UNDO +-- Name: update_labels_table +-- Description: Update labels table and create link_labels table + +BEGIN; + +ALTER TABLE omnivore.labels + ADD COLUMN link_id uuid REFERENCES omnivore.links ON DELETE CASCADE, + DROP COLUMN color, + DROP COLUMN description, + DROP CONSTRAINT label_name; + +DROP TABLE omnivore.link_labels; + +COMMIT; From e74f334db9352072e3c7731cc9cef3721ccd262e Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 21 Feb 2022 16:20:48 +0800 Subject: [PATCH 02/20] update entity --- packages/api/src/entity/label.ts | 14 +++++++++++--- packages/api/src/entity/link.ts | 6 ++++-- .../db/migrations/0070.do.update_labels_table.sql | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/api/src/entity/label.ts b/packages/api/src/entity/label.ts index b7081e0a7..27ee04da9 100644 --- a/packages/api/src/entity/label.ts +++ b/packages/api/src/entity/label.ts @@ -4,6 +4,8 @@ import { CreateDateColumn, Entity, JoinColumn, + JoinTable, + ManyToMany, ManyToOne, PrimaryGeneratedColumn, } from 'typeorm' @@ -22,9 +24,15 @@ export class Label extends BaseEntity { @JoinColumn({ name: 'user_id' }) user!: User - @ManyToOne(() => Link) - @JoinColumn({ name: 'link_id' }) - link!: Link + @ManyToMany(() => Link, (link) => link.labels) + @JoinTable({ name: 'link_labels' }) + link?: Link + + @Column('text') + color!: string + + @Column('text') + description?: string @CreateDateColumn() createdAt!: Date diff --git a/packages/api/src/entity/link.ts b/packages/api/src/entity/link.ts index cc3550cb1..fcd9ee2f4 100644 --- a/packages/api/src/entity/link.ts +++ b/packages/api/src/entity/link.ts @@ -15,7 +15,8 @@ import { CreateDateColumn, Entity, JoinColumn, - OneToMany, + JoinTable, + ManyToMany, OneToOne, PrimaryGeneratedColumn, UpdateDateColumn, @@ -62,6 +63,7 @@ export class Link extends BaseEntity { @UpdateDateColumn() updatedAt?: Date - @OneToMany(() => Label, (label) => label.link) + @ManyToMany(() => Label, (label) => label.link) + @JoinTable({ name: 'link_labels' }) labels?: Label[] } diff --git a/packages/db/migrations/0070.do.update_labels_table.sql b/packages/db/migrations/0070.do.update_labels_table.sql index 617376715..b3b5518d2 100755 --- a/packages/db/migrations/0070.do.update_labels_table.sql +++ b/packages/db/migrations/0070.do.update_labels_table.sql @@ -6,7 +6,7 @@ BEGIN; ALTER TABLE omnivore.labels DROP COLUMN link_id, - ADD COLUMN color text, + ADD COLUMN color text NOT NULL, ADD COLUMN description text, ADD CONSTRAINT label_name_unique UNIQUE (user_id, name); From 9150e7dbc2ce4763708099e71994ee509b2dccad Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 21 Feb 2022 16:53:04 +0800 Subject: [PATCH 03/20] add tests for create/delete labels --- packages/api/src/entity/label.ts | 2 +- packages/api/src/entity/user.ts | 4 ++ packages/api/src/generated/graphql.ts | 17 +++-- packages/api/src/generated/schema.graphql | 9 ++- packages/api/src/resolvers/labels/index.ts | 78 ++++++++++------------ packages/api/src/schema.ts | 9 ++- packages/api/test/resolvers/labels.test.ts | 67 ++++++++----------- 7 files changed, 94 insertions(+), 92 deletions(-) diff --git a/packages/api/src/entity/label.ts b/packages/api/src/entity/label.ts index 27ee04da9..dec2046f4 100644 --- a/packages/api/src/entity/label.ts +++ b/packages/api/src/entity/label.ts @@ -31,7 +31,7 @@ export class Label extends BaseEntity { @Column('text') color!: string - @Column('text') + @Column('text', { nullable: true }) description?: string @CreateDateColumn() diff --git a/packages/api/src/entity/user.ts b/packages/api/src/entity/user.ts index cc6af11c3..5aa1e5d05 100644 --- a/packages/api/src/entity/user.ts +++ b/packages/api/src/entity/user.ts @@ -11,6 +11,7 @@ import { import { MembershipTier, RegistrationType } from '../datalayer/user/model' import { NewsletterEmail } from './newsletter_email' import { Profile } from './profile' +import { Label } from './label' @Entity() export class User extends BaseEntity { @@ -46,4 +47,7 @@ export class User extends BaseEntity { @Column('varchar', { length: 255, nullable: true }) password?: string + + @OneToMany(() => Label, (label) => label.user) + labels?: Label[] } diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 622bf586d..e854ff299 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -276,12 +276,14 @@ export type CreateLabelError = { export enum CreateLabelErrorCode { BadRequest = 'BAD_REQUEST', + LabelAlreadyExists = 'LABEL_ALREADY_EXISTS', NotFound = 'NOT_FOUND', Unauthorized = 'UNAUTHORIZED' } export type CreateLabelInput = { - linkId: Scalars['ID']; + color: Scalars['String']; + description?: InputMaybe; name: Scalars['String']; }; @@ -624,6 +626,9 @@ export type HighlightStats = { export type Label = { __typename?: 'Label'; + color: Scalars['String']; + createdAt: Scalars['Date']; + description?: Maybe; id: Scalars['ID']; name: Scalars['String']; }; @@ -1120,11 +1125,6 @@ export type QueryGetFollowingArgs = { }; -export type QueryLabelsArgs = { - linkId: Scalars['ID']; -}; - - export type QueryReminderArgs = { linkId: Scalars['ID']; }; @@ -2762,6 +2762,9 @@ export type HighlightStatsResolvers = { + color?: Resolver; + createdAt?: Resolver; + description?: Resolver, ParentType, ContextType>; id?: Resolver; name?: Resolver; __isTypeOf?: IsTypeOfResolverFn; @@ -2955,7 +2958,7 @@ export type QueryResolvers>; getUserPersonalization?: Resolver; hello?: Resolver, ParentType, ContextType>; - labels?: Resolver>; + labels?: Resolver; me?: Resolver, ParentType, ContextType>; newsletterEmails?: Resolver; reminder?: Resolver>; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 55558990d..a6288950d 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -234,12 +234,14 @@ type CreateLabelError { enum CreateLabelErrorCode { BAD_REQUEST + LABEL_ALREADY_EXISTS NOT_FOUND UNAUTHORIZED } input CreateLabelInput { - linkId: ID! + color: String! + description: String name: String! } @@ -548,6 +550,9 @@ type HighlightStats { } type Label { + color: String! + createdAt: Date! + description: String id: ID! name: String! } @@ -787,7 +792,7 @@ type Query { getFollowing(userId: ID): GetFollowingResult! getUserPersonalization: GetUserPersonalizationResult! hello: String - labels(linkId: ID!): LabelsResult! + labels: LabelsResult! me: User newsletterEmails: NewsletterEmailsResult! reminder(linkId: ID!): ReminderResult! diff --git a/packages/api/src/resolvers/labels/index.ts b/packages/api/src/resolvers/labels/index.ts index e83e29b84..2852171e7 100644 --- a/packages/api/src/resolvers/labels/index.ts +++ b/packages/api/src/resolvers/labels/index.ts @@ -11,57 +11,47 @@ import { LabelsSuccess, MutationCreateLabelArgs, MutationDeleteLabelArgs, - QueryLabelsArgs, } from '../../generated/graphql' import { analytics } from '../../utils/analytics' import { env } from '../../env' import { User } from '../../entity/user' -import { Link } from '../../entity/link' import { Label } from '../../entity/label' import { getManager, getRepository } from 'typeorm' import { setClaims } from '../../entity/utils' -export const labelsResolver = authorized< - LabelsSuccess, - LabelsError, - QueryLabelsArgs ->(async (_, { linkId }, { claims: { uid }, log }) => { - log.info('labelsResolver') +export const labelsResolver = authorized( + async (_obj, _params, { claims: { uid }, log }) => { + log.info('labelsResolver') - analytics.track({ - userId: uid, - event: 'labels', - properties: { - linkId: linkId, - env: env.server.apiEnv, - }, - }) + analytics.track({ + userId: uid, + event: 'labels', + properties: { + env: env.server.apiEnv, + }, + }) - try { - const user = await User.findOne(uid) - if (!user) { - return { - errorCodes: [LabelsErrorCode.Unauthorized], + try { + const user = await User.findOne(uid, { + relations: ['labels'], + }) + if (!user) { + return { + errorCodes: [LabelsErrorCode.Unauthorized], + } } - } - const link = await Link.findOne(linkId, { relations: ['labels'] }) - if (!link) { return { - errorCodes: [LabelsErrorCode.NotFound], + labels: user.labels || [], + } + } catch (error) { + log.error(error) + return { + errorCodes: [LabelsErrorCode.BadRequest], } - } - - return { - labels: link.labels || [], - } - } catch (error) { - log.error(error) - return { - errorCodes: [LabelsErrorCode.BadRequest], } } -}) +) export const createLabelResolver = authorized< CreateLabelSuccess, @@ -70,7 +60,7 @@ export const createLabelResolver = authorized< >(async (_, { input }, { claims: { uid }, log }) => { log.info('createLabelResolver') - const { linkId, name } = input + const { name, color, description } = input try { const user = await getRepository(User).findOne(uid) @@ -80,18 +70,23 @@ export const createLabelResolver = authorized< } } - const link = await getRepository(Link).findOne(linkId) - if (!link) { + const existingLabel = await getRepository(Label).findOne({ + where: { + name, + }, + }) + if (existingLabel) { return { - errorCodes: [CreateLabelErrorCode.NotFound], + errorCodes: [CreateLabelErrorCode.LabelAlreadyExists], } } const label = await getRepository(Label) .create({ user, - link, name, + color, + description: description || '', }) .save() @@ -99,8 +94,9 @@ export const createLabelResolver = authorized< userId: uid, event: 'createLabel', properties: { - linkId, name, + color, + description, env: env.server.apiEnv, }, }) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index b746475bc..ce3613b72 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1242,6 +1242,9 @@ const schema = gql` type Label { id: ID! name: String! + color: String! + description: String + createdAt: Date! } type LabelsSuccess { @@ -1261,8 +1264,9 @@ const schema = gql` union LabelsResult = LabelsSuccess | LabelsError input CreateLabelInput { - linkId: ID! name: String! + color: String! + description: String } type CreateLabelSuccess { @@ -1273,6 +1277,7 @@ const schema = gql` UNAUTHORIZED BAD_REQUEST NOT_FOUND + LABEL_ALREADY_EXISTS } type CreateLabelError { @@ -1414,7 +1419,7 @@ const schema = gql` articleSavingRequest(id: ID!): ArticleSavingRequestResult! newsletterEmails: NewsletterEmailsResult! reminder(linkId: ID!): ReminderResult! - labels(linkId: ID!): LabelsResult! + labels: LabelsResult! } ` diff --git a/packages/api/test/resolvers/labels.test.ts b/packages/api/test/resolvers/labels.test.ts index 339411944..8fe06fd8e 100644 --- a/packages/api/test/resolvers/labels.test.ts +++ b/packages/api/test/resolvers/labels.test.ts @@ -36,14 +36,14 @@ describe('Labels API', () => { .create({ name: 'label1', user: user, - link: link, + color: '#ffffff', }) .save() const label2 = await getRepository(Label) .create({ name: 'label2', user: user, - link: link, + color: '#eeeeee', }) .save() labels = [label1, label2] @@ -56,16 +56,18 @@ describe('Labels API', () => { describe('GET labels', () => { let query: string - let linkId: string beforeEach(() => { query = ` query { - labels(linkId: "${linkId}") { + labels { ... on LabelsSuccess { labels { id name + color + description + createdAt } } ... on LabelsError { @@ -76,33 +78,18 @@ describe('Labels API', () => { ` }) - context('when link exists', () => { - before(() => { - linkId = link.id - }) + it('should return labels', async () => { + const res = await graphqlRequest(query, authToken).expect(200) - it('should return labels', async () => { - const res = await graphqlRequest(query, authToken).expect(200) - - expect(res.body.data.labels.labels).to.eql( - labels.map((label) => ({ - id: label.id, - name: label.name, - })) - ) - }) - }) - - context('when link not exist', () => { - before(() => { - linkId = generateFakeUuid() - }) - - it('should return error code NOT_FOUND', async () => { - const res = await graphqlRequest(query, authToken).expect(200) - - expect(res.body.data.labels.errorCodes).to.eql(['NOT_FOUND']) - }) + expect(res.body.data.labels.labels).to.eql( + labels.map((label) => ({ + id: label.id, + name: label.name, + color: label.color, + description: label.description, + createdAt: new Date(label.createdAt.setMilliseconds(0)).toISOString(), + })) + ) }) it('responds status code 400 when invalid query', async () => { @@ -122,15 +109,15 @@ describe('Labels API', () => { describe('Create label', () => { let query: string - let linkId: string + let name: string beforeEach(() => { query = ` mutation { createLabel( input: { - linkId: "${linkId}", - name: "label3" + color: "#ffffff" + name: "${name}" } ) { ... on CreateLabelSuccess { @@ -147,9 +134,9 @@ describe('Labels API', () => { ` }) - context('when link exists', () => { + context('when name not exists', () => { before(() => { - linkId = link.id + name = 'label3' }) it('should create label', async () => { @@ -161,15 +148,17 @@ describe('Labels API', () => { }) }) - context('when link not exist', () => { + context('when name exists', () => { before(() => { - linkId = generateFakeUuid() + name = labels[0].name }) - it('should return error code NOT_FOUND', async () => { + it('should return error code LABEL_ALREADY_EXISTS', async () => { const res = await graphqlRequest(query, authToken).expect(200) - expect(res.body.data.createLabel.errorCodes).to.eql(['NOT_FOUND']) + expect(res.body.data.createLabel.errorCodes).to.eql([ + 'LABEL_ALREADY_EXISTS', + ]) }) }) From 138c9682cd43362bb654c68b583b2a01c8455578 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 22 Feb 2022 14:18:55 +0800 Subject: [PATCH 04/20] update tables --- packages/db/migrations/0070.do.update_labels_table.sql | 4 +++- packages/db/migrations/0070.undo.update_labels_table.sql | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/db/migrations/0070.do.update_labels_table.sql b/packages/db/migrations/0070.do.update_labels_table.sql index b3b5518d2..47bd667eb 100755 --- a/packages/db/migrations/0070.do.update_labels_table.sql +++ b/packages/db/migrations/0070.do.update_labels_table.sql @@ -11,9 +11,11 @@ ALTER TABLE omnivore.labels ADD CONSTRAINT label_name_unique UNIQUE (user_id, name); CREATE TABLE omnivore.link_labels ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), link_id uuid NOT NULL REFERENCES omnivore.links ON DELETE CASCADE, label_id uuid NOT NULL REFERENCES omnivore.labels ON DELETE CASCADE, - created_at timestamptz NOT NULL DEFAULT current_timestamp + created_at timestamptz NOT NULL DEFAULT current_timestamp, + UNIQUE (link_id, label_id) ); COMMIT; diff --git a/packages/db/migrations/0070.undo.update_labels_table.sql b/packages/db/migrations/0070.undo.update_labels_table.sql index 8285b5197..f6f4ddcd1 100755 --- a/packages/db/migrations/0070.undo.update_labels_table.sql +++ b/packages/db/migrations/0070.undo.update_labels_table.sql @@ -8,7 +8,7 @@ ALTER TABLE omnivore.labels ADD COLUMN link_id uuid REFERENCES omnivore.links ON DELETE CASCADE, DROP COLUMN color, DROP COLUMN description, - DROP CONSTRAINT label_name; + DROP CONSTRAINT label_name_unique; DROP TABLE omnivore.link_labels; From d33b21309203c1f53ebacd76555ccb2fa0ee4908 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 22 Feb 2022 17:00:15 +0800 Subject: [PATCH 05/20] add setLabels api and tests --- packages/api/src/entity/label.ts | 7 - packages/api/src/entity/link.ts | 8 +- packages/api/src/entity/link_label.ts | 27 ++++ packages/api/src/generated/graphql.ts | 56 ++++++++ packages/api/src/generated/schema.graphql | 22 +++ .../api/src/resolvers/function_resolvers.ts | 3 + packages/api/src/resolvers/labels/index.ts | 73 ++++++++++ packages/api/src/schema.ts | 22 +++ packages/api/test/db.ts | 15 ++ packages/api/test/resolvers/labels.test.ts | 128 +++++++++++++++--- 10 files changed, 333 insertions(+), 28 deletions(-) create mode 100644 packages/api/src/entity/link_label.ts diff --git a/packages/api/src/entity/label.ts b/packages/api/src/entity/label.ts index dec2046f4..0b36eddec 100644 --- a/packages/api/src/entity/label.ts +++ b/packages/api/src/entity/label.ts @@ -4,13 +4,10 @@ import { CreateDateColumn, Entity, JoinColumn, - JoinTable, - ManyToMany, ManyToOne, PrimaryGeneratedColumn, } from 'typeorm' import { User } from './user' -import { Link } from './link' @Entity({ name: 'labels' }) export class Label extends BaseEntity { @@ -24,10 +21,6 @@ export class Label extends BaseEntity { @JoinColumn({ name: 'user_id' }) user!: User - @ManyToMany(() => Link, (link) => link.labels) - @JoinTable({ name: 'link_labels' }) - link?: Link - @Column('text') color!: string diff --git a/packages/api/src/entity/link.ts b/packages/api/src/entity/link.ts index fcd9ee2f4..40c0f208a 100644 --- a/packages/api/src/entity/link.ts +++ b/packages/api/src/entity/link.ts @@ -63,7 +63,11 @@ export class Link extends BaseEntity { @UpdateDateColumn() updatedAt?: Date - @ManyToMany(() => Label, (label) => label.link) - @JoinTable({ name: 'link_labels' }) + @ManyToMany(() => Label) + @JoinTable({ + name: 'link_labels', + joinColumn: { name: 'link_id' }, + inverseJoinColumn: { name: 'label_id' }, + }) labels?: Label[] } diff --git a/packages/api/src/entity/link_label.ts b/packages/api/src/entity/link_label.ts new file mode 100644 index 000000000..5bcdd64d6 --- /dev/null +++ b/packages/api/src/entity/link_label.ts @@ -0,0 +1,27 @@ +import { + BaseEntity, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm' +import { Link } from './link' +import { Label } from './label' + +@Entity({ name: 'link_labels' }) +export class LinkLabel extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => Link) + @JoinColumn({ name: 'link_id' }) + link!: Link + + @ManyToOne(() => Label) + @JoinColumn({ name: 'label_id' }) + label!: Label + + @CreateDateColumn() + createdAt!: Date +} diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index e854ff299..c844284eb 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -778,6 +778,7 @@ export type Mutation = { setBookmarkArticle: SetBookmarkArticleResult; setDeviceToken: SetDeviceTokenResult; setFollow: SetFollowResult; + setLabels: SetLabelsResult; setLinkArchived: ArchiveLinkResult; setShareArticle: SetShareArticleResult; setShareHighlight: SetShareHighlightResult; @@ -919,6 +920,11 @@ export type MutationSetFollowArgs = { }; +export type MutationSetLabelsArgs = { + input: SetLabelsInput; +}; + + export type MutationSetLinkArchivedArgs = { input: ArchiveLinkInput; }; @@ -1350,6 +1356,29 @@ export type SetFollowSuccess = { updatedUser: User; }; +export type SetLabelsError = { + __typename?: 'SetLabelsError'; + errorCodes: Array; +}; + +export enum SetLabelsErrorCode { + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + +export type SetLabelsInput = { + labelIds: Array; + linkId: Scalars['ID']; +}; + +export type SetLabelsResult = SetLabelsError | SetLabelsSuccess; + +export type SetLabelsSuccess = { + __typename?: 'SetLabelsSuccess'; + labels: Array