From 20b4234211a0317baf8ab8c751c151a25c903e8e Mon Sep 17 00:00:00 2001 From: gitwoz <177856586+gitwoz@users.noreply.github.com> Date: Thu, 14 Nov 2024 12:31:00 +0700 Subject: [PATCH 1/2] feat(tag): add "Tag.recommendedAuthors" --- .../20241112112619_create_tag_stats_view.js | 58 +++++ ...0241112165500_create_article_stats_view.js | 27 +++ .../20241113094622_create_tag_hottest_view.js | 71 ++++++ schema.graphql | 4 +- src/common/enums/table.ts | 7 +- src/connectors/tagService.ts | 212 +++++++----------- src/definitions/index.d.ts | 6 +- src/definitions/schema.d.ts | 14 +- src/queries/article/index.ts | 2 + src/queries/article/tag/articles.ts | 35 ++- src/queries/article/tag/recommendedAuthors.ts | 21 ++ src/queries/user/recommendation/tags.ts | 3 +- src/types/__test__/2/user.test.ts | 2 +- src/types/article.ts | 4 +- 14 files changed, 304 insertions(+), 162 deletions(-) create mode 100644 db/migrations/20241112112619_create_tag_stats_view.js create mode 100644 db/migrations/20241112165500_create_article_stats_view.js create mode 100644 db/migrations/20241113094622_create_tag_hottest_view.js create mode 100644 src/queries/article/tag/recommendedAuthors.ts diff --git a/db/migrations/20241112112619_create_tag_stats_view.js b/db/migrations/20241112112619_create_tag_stats_view.js new file mode 100644 index 000000000..8e6fc2752 --- /dev/null +++ b/db/migrations/20241112112619_create_tag_stats_view.js @@ -0,0 +1,58 @@ +const materialized = 'tag_stats_materialized' + +exports.up = async (knex) => { + await knex.raw(`DROP MATERIALIZED VIEW IF EXISTS ${materialized}`) + + await knex.raw(` + CREATE MATERIALIZED VIEW ${materialized} AS + WITH article_tags AS ( + SELECT + at.tag_id, + at.article_id, + article.author_id + FROM article_tag at + INNER JOIN article + ON article.id = at.article_id + INNER JOIN "user" u + ON u.id = article.author_id + WHERE article.state = 'active' + AND u.state NOT IN ('forzen', 'archived') + AND u.id NOT IN ( + SELECT user_id + FROM user_restriction + ) + AND at.created_at AT TIME ZONE 'Asia/Taipei' >= NOW() - INTERVAL '12 months' + AND at.created_at AT TIME ZONE 'Asia/Taipei' < NOW() + ), + tag_stats AS ( + SELECT + tag_id, + COUNT(article_id)::INT AS all_articles, + COUNT(DISTINCT author_id)::INT AS all_users + FROM article_tags + GROUP BY tag_id + ), + user_threshold AS ( + SELECT DISTINCT + PERCENTILE_CONT(0.15) WITHIN GROUP (ORDER BY all_users) AS threshold + FROM tag_stats + ) + SELECT + ts.tag_id, + tag.content, + ts.all_articles, + ts.all_users + FROM tag_stats ts + INNER JOIN tag + ON tag.id = ts.tag_id + CROSS JOIN user_threshold ut + WHERE ts.all_users > ut.threshold + ORDER BY + ts.all_users DESC, + ts.all_articles DESC + `) +} + +exports.down = async (knex) => { + await knex.raw(`DROP MATERIALIZED VIEW IF EXISTS ${materialized}`) +} diff --git a/db/migrations/20241112165500_create_article_stats_view.js b/db/migrations/20241112165500_create_article_stats_view.js new file mode 100644 index 000000000..e6b257ff7 --- /dev/null +++ b/db/migrations/20241112165500_create_article_stats_view.js @@ -0,0 +1,27 @@ +const table = 'article_stats_materialized' + +exports.up = async (knex) => { + await knex.raw(` + CREATE MATERIALIZED VIEW ${table} AS + SELECT + COALESCE(r.article_id, c.reference_id) as article_id, + COALESCE(r.reads, 0) as reads, + COALESCE(c.claps, 0) as claps + FROM ( + SELECT article_id, sum(timed_count)::int AS reads + FROM article_read_count + WHERE user_id IS NOT NULL + GROUP BY article_id + ) r + FULL OUTER JOIN ( + SELECT reference_id, sum(amount)::int AS claps + FROM appreciation + WHERE purpose = 'appreciate' + GROUP BY reference_id + ) c ON r.article_id = c.reference_id + `) +} + +exports.down = async (knex) => { + await knex.raw(`DROP MATERIALIZED VIEW IF EXISTS ${table}`) +} diff --git a/db/migrations/20241113094622_create_tag_hottest_view.js b/db/migrations/20241113094622_create_tag_hottest_view.js new file mode 100644 index 000000000..490063fd0 --- /dev/null +++ b/db/migrations/20241113094622_create_tag_hottest_view.js @@ -0,0 +1,71 @@ +const table = 'tag_hottest_materialized' + +exports.up = async (knex) => { + await knex.raw(` + CREATE MATERIALIZED VIEW ${table} AS + WITH raw_article_data AS ( + SELECT + at.tag_id, + to_char(article.created_at, 'YYYYMM') AS month, + at.article_id, + article.author_id + FROM article_tag AS at + INNER JOIN article ON article.id = at.article_id + INNER JOIN "user" AS u ON u.id = article.author_id + WHERE article.state = 'active' + AND u.state NOT in('frozen', 'archived') + AND u.id NOT in(SELECT user_id FROM user_restriction) + ), + monthly_stats AS ( + SELECT tag_id, month, + count(article_id)::int articles, + count(DISTINCT author_id)::int users + FROM raw_article_data + GROUP BY tag_id, month + ), + tag_averages AS ( + SELECT tag_id, + count(month) AS months, + avg(articles) AS mean_articles, + avg(users) AS mean_users + FROM monthly_stats + GROUP BY tag_id + ), + tag_z_scores AS ( + SELECT tag_id, months, + (months - avg(months) OVER()) / NULLIF(stddev(months) OVER(), 0) AS z_months, + mean_articles, + (mean_articles - avg(mean_articles) OVER()) / NULLIF(stddev(mean_articles) OVER(), 0) AS z_articles, + mean_users, + (mean_users - avg(mean_users) OVER()) / NULLIF(stddev(mean_users) OVER(), 0) AS z_users + FROM tag_averages + ), + significant_scores AS ( + SELECT tag_id, months, + CASE WHEN z_months < 2 THEN 0 ELSE z_months END AS z_months, + mean_articles, + CASE WHEN z_articles < 2 THEN 0 ELSE z_articles END AS z_articles, + mean_users, + CASE WHEN z_users < 2 THEN 0 ELSE z_users END AS z_users + FROM tag_z_scores + ) + SELECT + base.tag_id, + tag.content, + base.months, + base.mean_articles, + base.mean_users, + base.score + FROM ( + SELECT *, z_months * z_articles * z_users AS score + FROM significant_scores + ) AS base + INNER JOIN tag ON tag.id = base.tag_id + WHERE base.score > 0 + ORDER BY base.score DESC + `) +} + +exports.down = async (knex) => { + await knex.raw(`DROP MATERIALIZED VIEW IF EXISTS ${table}`) +} diff --git a/schema.graphql b/schema.graphql index 02f55dde5..76ca31ec5 100644 --- a/schema.graphql +++ b/schema.graphql @@ -519,6 +519,9 @@ type Tag implements Node { """Tags recommended based on relations to current tag.""" recommended(input: ConnectionArgs!): TagConnection! + """Authors recommended based on relations to current tag.""" + recommendedAuthors(input: ConnectionArgs!): UserConnection! + """Counts of this tag.""" numArticles: Int! numAuthors: Int! @@ -709,7 +712,6 @@ input TagArticlesInput { after: String first: Int oss: Boolean - selected: Boolean sortBy: TagArticlesSortBy = byCreatedAtDesc } diff --git a/src/common/enums/table.ts b/src/common/enums/table.ts index 14ab5bbcd..dc296ea5d 100644 --- a/src/common/enums/table.ts +++ b/src/common/enums/table.ts @@ -1,5 +1,5 @@ export enum VIEW { - tag_count_view = 'tag_count_view', + // tag_count_view = 'tag_count_view', user_reader_view = 'user_reader_view', article_count_view = 'article_count_view', article_hottest_view = 'article_hottest_view', @@ -9,8 +9,11 @@ export enum VIEW { } export enum MATERIALIZED_VIEW { - tag_count_materialized = 'tag_count_materialized', + // tag_count_materialized = 'tag_count_materialized', tags_lasts_view_materialized = 'mat_views.tags_lasts_view_materialized', + tag_stats_materialized = 'tag_stats_materialized', + tag_hottest_materialized = 'tag_hottest_materialized', + article_stats_materialized = 'article_stats_materialized', user_reader_materialized = 'user_reader_materialized', featured_comment_materialized = 'featured_comment_materialized', curation_tag_materialized = 'curation_tag_materialized', diff --git a/src/connectors/tagService.ts b/src/connectors/tagService.ts index f2c74b7f1..130b98747 100644 --- a/src/connectors/tagService.ts +++ b/src/connectors/tagService.ts @@ -503,56 +503,20 @@ export class TagService extends BaseService { public findTopTags = ({ take = 50, skip, - top = 'r3m', minAuthors, }: { take?: number skip?: number - // recent 1 week, 1 month, or 3 months? - top?: 'r1w' | 'r2w' | 'r1m' | 'r3m' minAuthors?: number }): Promise> => this.knex - .select('id') - .from(MATERIALIZED_VIEW.tags_lasts_view_materialized) - .modify(function (this: Knex.QueryBuilder) { + .select('tag_id as id') + .from(MATERIALIZED_VIEW.tag_stats_materialized) + .modify((builder: Knex.QueryBuilder) => { if (minAuthors) { - this.where('num_authors', '>=', minAuthors) - } - switch (top) { - case 'r1w': - this.orderByRaw( - 'num_authors_r1w DESC NULLS LAST, num_articles_r1w DESC NULLS LAST' - ) - // no break to fallthrough - case 'r2w': - this.orderByRaw( - 'num_authors_r2w DESC NULLS LAST, num_articles_r2w DESC NULLS LAST' - ) - // no break to fallthrough - case 'r1m': - this.orderByRaw( - 'num_authors_r1m DESC NULLS LAST, num_articles_r1m DESC NULLS LAST' - ) - // no break to fallthrough - case 'r3m': - // always use recent3months as fallback - this.orderByRaw( - 'num_authors_r3m DESC NULLS LAST, num_articles_r3m DESC NULLS LAST' - ) - /* this orderBy does not work as documented - .orderBy([ - { column: 'num_authors_r3m', order: 'desc', nulls: 'last' }, - { column: 'num_articles_r3m', order: 'desc', nulls: 'last' }, - { column: 'span_days', order: 'desc', nulls: 'last' }, - ]) - */ + builder.where('all_users', '>=', minAuthors) } }) - // last fallback - .orderByRaw('num_authors DESC NULLS LAST, num_articles DESC NULLS LAST') - .orderByRaw('span_days DESC NULLS LAST') - .orderByRaw('created_at') // ascending from earliest to latest .modify((builder: Knex.QueryBuilder) => { if (skip !== undefined && Number.isFinite(skip)) { builder.offset(skip) @@ -639,44 +603,11 @@ export class TagService extends BaseService { /** * Count article authors by a given tag id. */ - public countAuthors = async ({ - id: tagId, - selected, - withSynonyms = true, - }: { - id: string - selected?: boolean - withSynonyms?: boolean - }) => { - const knex = this.knex - - let result: any - try { - result = await this.knex(MATERIALIZED_VIEW.tags_lasts_view_materialized) - .select('id', 'content', 'id_slug', 'num_authors', 'num_articles') - .where(function (this: Knex.QueryBuilder) { - this.where('id', '=', tagId) - if (withSynonyms) { - this.orWhere(knex.raw(`dup_tag_ids @> ARRAY[?] ::int[]`, [tagId])) - } // else { this.where('id', tagId) // exactly } - }) - .first() - } catch (err) { - // empty; do nothing - } - - if (result?.numAuthors) { - return parseInt(result.numAuthors ?? '0', 10) - } - - result = await this.knex('article_tag') + public countAuthors = async ({ id: tagId }: { id: string }) => { + const result = await this.knex('article_tag') .join('article', 'article_id', 'article.id') .countDistinct('author_id') - .where({ - // 'article_tag.tag_id': tagId, - tagId, - state: ARTICLE_STATE.active, - }) + .where({ tagId, state: ARTICLE_STATE.active }) .first() return parseInt(result ? (result.count as string) : '0', 10) @@ -685,47 +616,85 @@ export class TagService extends BaseService { /** * Count articles by a given tag id. */ - public countArticles = async ({ + public countArticles = async ({ id: tagId }: { id: string }) => { + const result = await this.knexRO('article_tag') + .join('article', 'article_id', 'article.id') + .countDistinct('article_id') + .first() + .where({ tagId, state: ARTICLE_STATE.active }) + + return parseInt(result ? (result.count as string) : '0', 10) + } + + private getHottestArticlesBaseQuery = (tagId: string) => { + return this.knexRO.with('tagged_articles', (builder) => + builder + .select('article.id', 'article_stats.reads', 'article_stats.claps') + .from('article_tag') + .innerJoin('article', 'article.id', 'article_tag.article_id') + .innerJoin( + 'article_version_newest as avn', + 'avn.article_id', + 'article_tag.article_id' + ) + .leftJoin( + 'article_stats_materialized as article_stats', + 'article_stats.article_id', + 'article_tag.article_id' + ) + .where({ + 'article_tag.tag_id': tagId, + 'article.state': ARTICLE_STATE.active, + }) + ) + } + + public findHottestArticleIds = async ({ id: tagId, - selected, - withSynonyms = true, + skip, + take, }: { id: string - selected?: boolean - withSynonyms?: boolean + skip?: number + take?: number }) => { - const knexRO = this.knexRO + const hasHottest = await this.knex( + MATERIALIZED_VIEW.tag_hottest_materialized + ) + .where({ tagId }) + .first() - let result: any - try { - result = await this.knexRO(MATERIALIZED_VIEW.tags_lasts_view_materialized) - .select('id', 'content', 'id_slug', 'num_authors', 'num_articles') - .where(function (this: Knex.QueryBuilder) { - this.where('tag_id', tagId) - if (withSynonyms) { - this.orWhere(knexRO.raw(`dup_tag_ids @> ARRAY[?] ::int[]`, [tagId])) - } // else { this.where('id', tagId) // exactly } - }) - .first() - } catch (err) { - // empty; do nothing + if (!hasHottest) { + return [] } - if (result?.numArticles) { - return parseInt(result.numArticles ?? '0', 10) - } + const results = await this.getHottestArticlesBaseQuery(tagId) + .select('id as article_id') + .orderByRaw('(COALESCE(reads, 0) + COALESCE(claps, 0)) DESC') + .modify((builder: Knex.QueryBuilder) => { + if (skip !== undefined && Number.isFinite(skip)) { + builder.offset(skip) + } + if (take !== undefined && Number.isFinite(take)) { + builder.limit(take) + } + }) - result = await this.knexRO('article_tag') - .join('article', 'article_id', 'article.id') - .countDistinct('article_id') + return results.map(({ articleId }: { articleId: string }) => articleId) + } + + public countHottestArticles = async ({ id: tagId }: { id: string }) => { + const hasHottest = await this.knex( + MATERIALIZED_VIEW.tag_hottest_materialized + ) + .where({ tagId }) .first() - .where({ - // tagId: id, - // 'article_tag.tag_id': tagId, - tagId, - state: ARTICLE_STATE.active, - ...(selected === true ? { selected } : {}), - }) + + if (!hasHottest) { + return 0 + } + + const result = await this.getHottestArticlesBaseQuery(tagId).count().first() return parseInt(result ? (result.count as string) : '0', 10) } @@ -735,18 +704,12 @@ export class TagService extends BaseService { */ public findArticleIds = async ({ id: tagId, - selected, - sortBy, - withSynonyms, excludeRestricted, excludeSpam, skip, take, }: { id: string - selected?: boolean - sortBy?: 'byHottestDesc' | 'byCreatedAtDesc' - withSynonyms?: boolean excludeRestricted?: boolean excludeSpam?: boolean skip?: number @@ -760,19 +723,9 @@ export class TagService extends BaseService { .join('article', 'article_id', 'article.id') .where({ state: ARTICLE_STATE.active, - ...(selected === true ? { selected } : {}), }) .andWhere((builder: Knex.QueryBuilder) => { builder.where('tag_id', tagId) - if (withSynonyms) { - builder.orWhereIn( - 'tag_id', - this.knexRO - .from(MATERIALIZED_VIEW.tags_lasts_view_materialized) - .whereRaw('dup_tag_ids @> ARRAY[?] ::int[]', tagId) - .select(this.knex.raw('UNNEST(dup_tag_ids)')) - ) - } }) .modify((builder: Knex.QueryBuilder) => { if (excludeRestricted) { @@ -793,16 +746,7 @@ export class TagService extends BaseService { if (excludeSpam) { builder.modify(excludeSpamModifier, spamThreshold) } - if (sortBy === 'byHottestDesc') { - builder - .join( - // instead of leftJoin, only shows articles from materialized - 'article_hottest_materialized AS ah', - 'ah.id', - 'article.id' - ) - .orderByRaw(`score DESC NULLS LAST`) - } + builder.orderBy('article.id', 'desc') if (skip !== undefined && Number.isFinite(skip)) { diff --git a/src/definitions/index.d.ts b/src/definitions/index.d.ts index 9abf58bea..9fd4f6254 100644 --- a/src/definitions/index.d.ts +++ b/src/definitions/index.d.ts @@ -280,7 +280,8 @@ type OtherTable = | 'user_notify_setting' export type View = - | 'tag_count_view' + // | 'tag_count_view' + | 'tag_stats_view' | 'user_reader_view' | 'article_count_view' | 'article_hottest_view' @@ -290,7 +291,8 @@ export type View = export type MaterializedView = | 'article_count_materialized' - | 'tag_count_materialized' + // | 'tag_count_materialized' + | 'tag_stats_materialized' | 'user_reader_materialized' | 'article_value_materialized' | 'featured_comment_materialized' diff --git a/src/definitions/schema.d.ts b/src/definitions/schema.d.ts index 4bab768f3..57a515f98 100644 --- a/src/definitions/schema.d.ts +++ b/src/definitions/schema.d.ts @@ -3397,6 +3397,8 @@ export type GQLTag = GQLNode & { oss: GQLTagOss /** Tags recommended based on relations to current tag. */ recommended: GQLTagConnection + /** Authors recommended based on relations to current tag. */ + recommendedAuthors: GQLUserConnection remark?: Maybe } @@ -3410,11 +3412,15 @@ export type GQLTagRecommendedArgs = { input: GQLConnectionArgs } +/** This type contains content, count and related data of an article tag. */ +export type GQLTagRecommendedAuthorsArgs = { + input: GQLConnectionArgs +} + export type GQLTagArticlesInput = { after?: InputMaybe first?: InputMaybe oss?: InputMaybe - selected?: InputMaybe sortBy?: InputMaybe } @@ -8969,6 +8975,12 @@ export type GQLTagResolvers< ContextType, RequireFields > + recommendedAuthors?: Resolver< + GQLResolversTypes['UserConnection'], + ParentType, + ContextType, + RequireFields + > remark?: Resolver, ParentType, ContextType> __isTypeOf?: IsTypeOfResolverFn }> diff --git a/src/queries/article/index.ts b/src/queries/article/index.ts index bb743b337..78e9c0c8f 100644 --- a/src/queries/article/index.ts +++ b/src/queries/article/index.ts @@ -55,6 +55,7 @@ import tagNumArticles from './tag/numArticles' import tagNumAuthors from './tag/numAuthors' import * as tagOSS from './tag/oss' import tagsRecommended from './tag/recommended' +import tagsRecommendedAuthors from './tag/recommendedAuthors' import tags from './tags' import title from './title' import transactionsReceivedBy from './transactionsReceivedBy' @@ -134,6 +135,7 @@ const schema: GQLResolvers = { numAuthors: tagNumAuthors, oss: (root) => root, recommended: tagsRecommended, + recommendedAuthors: tagsRecommendedAuthors, }, ArticleVersion: { id: ({ id }) => toGlobalId({ type: NODE_TYPES.ArticleVersion, id }), diff --git a/src/queries/article/tag/articles.ts b/src/queries/article/tag/articles.ts index 4d6eaeac7..2fe625e47 100644 --- a/src/queries/article/tag/articles.ts +++ b/src/queries/article/tag/articles.ts @@ -7,27 +7,26 @@ const resolver: GQLTagResolvers['articles'] = async ( { input }, { dataSources: { tagService, atomService } } ) => { - const { selected, sortBy } = input + const { sortBy } = input const { take, skip } = fromConnectionArgs(input) - - const isFromRecommendation = - ((root as any).numArticles || (root as any).numAuthors) > 0 + const isHottest = sortBy === 'byHottestDesc' const [totalCount, articleIds] = await Promise.all([ - tagService.countArticles({ - id: root.id, - selected, - withSynonyms: isFromRecommendation, - }), - tagService.findArticleIds({ - id: root.id, - selected, - sortBy: sortBy as 'byHottestDesc' | 'byCreatedAtDesc' | undefined, - withSynonyms: isFromRecommendation, - excludeSpam: true, - skip, - take, - }), + isHottest + ? tagService.countHottestArticles({ id: root.id }) + : tagService.countArticles({ id: root.id }), + isHottest + ? tagService.findHottestArticleIds({ + id: root.id, + skip, + take, + }) + : tagService.findArticleIds({ + id: root.id, + excludeSpam: true, + skip, + take, + }), ]) return connectionFromPromisedArray( diff --git a/src/queries/article/tag/recommendedAuthors.ts b/src/queries/article/tag/recommendedAuthors.ts new file mode 100644 index 000000000..e1519c4ce --- /dev/null +++ b/src/queries/article/tag/recommendedAuthors.ts @@ -0,0 +1,21 @@ +import type { GQLTagResolvers } from 'definitions' + +import { connectionFromArray } from 'common/utils' + +const resolver: GQLTagResolvers['recommendedAuthors'] = async ( + { id }, + { input }, + { dataSources: { tagService, atomService } } +) => { + // const { take, skip } = fromConnectionArgs(input) + + if (!id) { + return connectionFromArray([], input) + } + + return connectionFromArray([], input) + + // return connectionFromPromisedArray(tags.slice(s, end), input, totalCount) +} + +export default resolver diff --git a/src/queries/user/recommendation/tags.ts b/src/queries/user/recommendation/tags.ts index 255d35d26..46517f577 100644 --- a/src/queries/user/recommendation/tags.ts +++ b/src/queries/user/recommendation/tags.ts @@ -29,8 +29,7 @@ export const tags: GQLRecommendationResolvers['tags'] = async ( const curationTags = await tagService.findTopTags({ take: limit * draw, - top: 'r2w', - minAuthors: 5, // show at least 5 authors in the curation + minAuthors: 5, }) const chunks = chunk(curationTags, draw) diff --git a/src/types/__test__/2/user.test.ts b/src/types/__test__/2/user.test.ts index 3b1ec3325..83b89da8b 100644 --- a/src/types/__test__/2/user.test.ts +++ b/src/types/__test__/2/user.test.ts @@ -1032,7 +1032,7 @@ describe('user recommendations', () => { test('retrieve tags from tags', async () => { await refreshView(MATERIALIZED_VIEW.curation_tag_materialized, knex) - await refreshView(MATERIALIZED_VIEW.tag_count_materialized, knex) + // await refreshView(MATERIALIZED_VIEW.tag_count_materialized, knex) const serverNew = await testClient({ isAuth: true, diff --git a/src/types/article.ts b/src/types/article.ts index dc9294a57..4707f0c97 100644 --- a/src/types/article.ts +++ b/src/types/article.ts @@ -272,6 +272,9 @@ export default /* GraphQL */ ` "Tags recommended based on relations to current tag." recommended(input: ConnectionArgs!): TagConnection! @complexity(multipliers: ["input.first"], value: 1) + "Authors recommended based on relations to current tag." + recommendedAuthors(input: ConnectionArgs!): UserConnection! @complexity(multipliers: ["input.first"], value: 1) + "Counts of this tag." numArticles: Int! @objectCache(maxAge: ${CACHE_TTL.MEDIUM}) ## cache for 1 hour numAuthors: Int! @objectCache(maxAge: ${CACHE_TTL.MEDIUM}) ## cache for 1 hour @@ -466,7 +469,6 @@ export default /* GraphQL */ ` after: String first: Int @constraint(min: 0) oss: Boolean - selected: Boolean sortBy: TagArticlesSortBy = byCreatedAtDesc } From 73cfa06f40817452474002335305fccb10fbfbc5 Mon Sep 17 00:00:00 2001 From: gitwoz <177856586+gitwoz@users.noreply.github.com> Date: Fri, 15 Nov 2024 10:46:43 +0700 Subject: [PATCH 2/2] feat(tag): make alias for tag mutations --- schema.graphql | 18 +++--- src/common/enums/table.ts | 3 - src/connectors/tagService.ts | 7 +++ src/definitions/schema.d.ts | 55 ++++++++++++------- src/mutations/article/index.ts | 5 +- ...ibeArticle.ts => toggleBookmarkArticle.ts} | 4 +- src/mutations/user/index.ts | 5 +- ...oggleFollowTag.ts => toggleBookmarkTag.ts} | 2 +- .../article/{subscribed.ts => bookmarked.ts} | 6 +- src/queries/article/index.ts | 7 +-- src/queries/article/subscribers.ts | 41 -------------- src/queries/user/recommendation/tags.ts | 7 +-- src/types/__test__/2/user.test.ts | 1 - src/types/article.ts | 19 +++---- 14 files changed, 77 insertions(+), 103 deletions(-) rename src/mutations/article/{toggleSubscribeArticle.ts => toggleBookmarkArticle.ts} (94%) rename src/mutations/user/{toggleFollowTag.ts => toggleBookmarkTag.ts} (94%) rename src/queries/article/{subscribed.ts => bookmarked.ts} (73%) delete mode 100644 src/queries/article/subscribers.ts diff --git a/schema.graphql b/schema.graphql index 76ca31ec5..93e1d7fec 100644 --- a/schema.graphql +++ b/schema.graphql @@ -43,8 +43,9 @@ type Mutation { """Edit an article.""" editArticle(input: EditArticleInput!): Article! - """Subscribe or Unsubscribe article""" - toggleSubscribeArticle(input: ToggleItemInput!): Article! + """Bookmark or unbookmark article""" + toggleSubscribeArticle(input: ToggleItemInput!): Article! @deprecated(reason: "Use toggleBookmarkArticle instead") + toggleBookmarkArticle(input: ToggleItemInput!): Article! """Appreciate an article.""" appreciateArticle(input: AppreciateArticleInput!): Article! @@ -52,8 +53,9 @@ type Mutation { """Read an article.""" readArticle(input: ReadArticleInput!): Article! - """Follow or unfollow tag.""" - toggleFollowTag(input: ToggleItemInput!): Tag! + """Bookmark or unbookmark tag.""" + toggleFollowTag(input: ToggleItemInput!): Tag! @deprecated(reason: "Use toggleBookmarkTag instead") + toggleBookmarkTag(input: ToggleItemInput!): Tag! toggleArticleRecommend(input: ToggleRecommendInput!): Article! updateArticleState(input: UpdateArticleStateInput!): Article! updateArticleSensitive(input: UpdateArticleSensitiveInput!): Article! @@ -362,9 +364,6 @@ type Article implements Node & PinnableWork { """Total number of readers of this article.""" readerCount: Int! - """Subscribers of this article.""" - subscribers(input: ConnectionArgs!): UserConnection! - """Limit the nuhmber of appreciate per user.""" appreciateLimit: Int! @@ -377,8 +376,9 @@ type Article implements Node & PinnableWork { """This value determines if current viewer can SuperLike or not.""" canSuperLike: Boolean! - """This value determines if current Viewer has subscribed of not.""" - subscribed: Boolean! + """This value determines if current Viewer has bookmarked of not.""" + subscribed: Boolean! @deprecated(reason: "Use bookmarked instead") + bookmarked: Boolean! """ This value determines if this article is an author selected article or not. diff --git a/src/common/enums/table.ts b/src/common/enums/table.ts index dc296ea5d..f8569d9da 100644 --- a/src/common/enums/table.ts +++ b/src/common/enums/table.ts @@ -1,15 +1,12 @@ export enum VIEW { - // tag_count_view = 'tag_count_view', user_reader_view = 'user_reader_view', article_count_view = 'article_count_view', article_hottest_view = 'article_hottest_view', transaction_delta_view = 'transaction_delta_view', article_value_view = 'article_value_view', - tags_lasts_view = 'mat_views.tags_lasts', } export enum MATERIALIZED_VIEW { - // tag_count_materialized = 'tag_count_materialized', tags_lasts_view_materialized = 'mat_views.tags_lasts_view_materialized', tag_stats_materialized = 'tag_stats_materialized', tag_hottest_materialized = 'tag_hottest_materialized', diff --git a/src/connectors/tagService.ts b/src/connectors/tagService.ts index 130b98747..fe5f1621c 100644 --- a/src/connectors/tagService.ts +++ b/src/connectors/tagService.ts @@ -500,6 +500,13 @@ export class TagService extends BaseService { return tag.tagScore || 0 } + public countTopTags = async () => { + const result = await this.knex(MATERIALIZED_VIEW.tag_stats_materialized) + .count() + .first() + return parseInt(result ? (result.count as string) : '0', 10) + } + public findTopTags = ({ take = 50, skip, diff --git a/src/definitions/schema.d.ts b/src/definitions/schema.d.ts index 57a515f98..3d88b7648 100644 --- a/src/definitions/schema.d.ts +++ b/src/definitions/schema.d.ts @@ -184,6 +184,7 @@ export type GQLArticle = GQLNode & author: GQLUser /** Available translation languages. */ availableTranslations?: Maybe> + bookmarked: Scalars['Boolean']['output'] /** associated campaigns */ campaigns: Array /** whether readers can comment */ @@ -270,10 +271,11 @@ export type GQLArticle = GQLNode & slug: Scalars['String']['output'] /** State of this article. */ state: GQLArticleState - /** This value determines if current Viewer has subscribed of not. */ + /** + * This value determines if current Viewer has bookmarked of not. + * @deprecated Use bookmarked instead + */ subscribed: Scalars['Boolean']['output'] - /** Subscribers of this article. */ - subscribers: GQLUserConnection /** A short summary for this article. */ summary: Scalars['String']['output'] /** This value determines if the summary is customized or not. */ @@ -366,14 +368,6 @@ export type GQLArticleResponsesArgs = { input: GQLResponsesInput } -/** - * This type contains metadata, content, hash and related data of an article. If you - * want information about article's comments. Please check Comment type. - */ -export type GQLArticleSubscribersArgs = { - input: GQLConnectionArgs -} - /** * This type contains metadata, content, hash and related data of an article. If you * want information about article's comments. Please check Comment type. @@ -1920,19 +1914,27 @@ export type GQLMutation = { toggleArticleRecommend: GQLArticle /** Block or Unblock a given user. */ toggleBlockUser: GQLUser + toggleBookmarkArticle: GQLArticle + toggleBookmarkTag: GQLTag /** * Follow or unfollow a Circle. * @deprecated No longer in use */ toggleFollowCircle: GQLCircle - /** Follow or unfollow tag. */ + /** + * Bookmark or unbookmark tag. + * @deprecated Use toggleBookmarkTag instead + */ toggleFollowTag: GQLTag /** Follow or Unfollow current user. */ toggleFollowUser: GQLUser /** Pin or Unpin a comment. */ togglePinComment: GQLComment toggleSeedingUsers: Array> - /** Subscribe or Unsubscribe article */ + /** + * Bookmark or unbookmark article + * @deprecated Use toggleBookmarkArticle instead + */ toggleSubscribeArticle: GQLArticle toggleUsersBadge: Array> toggleWritingChallengeFeaturedArticles: GQLCampaign @@ -2246,6 +2248,14 @@ export type GQLMutationToggleBlockUserArgs = { input: GQLToggleItemInput } +export type GQLMutationToggleBookmarkArticleArgs = { + input: GQLToggleItemInput +} + +export type GQLMutationToggleBookmarkTagArgs = { + input: GQLToggleItemInput +} + export type GQLMutationToggleFollowCircleArgs = { input: GQLToggleItemInput } @@ -5866,6 +5876,7 @@ export type GQLArticleResolvers< ParentType, ContextType > + bookmarked?: Resolver campaigns?: Resolver< Array, ParentType, @@ -5999,12 +6010,6 @@ export type GQLArticleResolvers< slug?: Resolver state?: Resolver subscribed?: Resolver - subscribers?: Resolver< - GQLResolversTypes['UserConnection'], - ParentType, - ContextType, - RequireFields - > summary?: Resolver summaryCustomized?: Resolver< GQLResolversTypes['Boolean'], @@ -7938,6 +7943,18 @@ export type GQLMutationResolvers< ContextType, RequireFields > + toggleBookmarkArticle?: Resolver< + GQLResolversTypes['Article'], + ParentType, + ContextType, + RequireFields + > + toggleBookmarkTag?: Resolver< + GQLResolversTypes['Tag'], + ParentType, + ContextType, + RequireFields + > toggleFollowCircle?: Resolver< GQLResolversTypes['Circle'], ParentType, diff --git a/src/mutations/article/index.ts b/src/mutations/article/index.ts index d1c68f96f..9bf6615c6 100644 --- a/src/mutations/article/index.ts +++ b/src/mutations/article/index.ts @@ -6,7 +6,7 @@ import publishArticle from './publishArticle' import readArticle from './readArticle' import renameTag from './renameTag' import toggleArticleRecommend from './toggleArticleRecommend' -import toggleSubscribeArticle from './toggleSubscribeArticle' +import toggleBookmarkArticle from './toggleBookmarkArticle' import updateArticleSensitive from './updateArticleSensitive' import updateArticleState from './updateArticleState' @@ -17,7 +17,8 @@ export default { appreciateArticle, readArticle, toggleArticleRecommend, - toggleSubscribeArticle, + toggleBookmarkArticle, + toggleSubscribeArticle: toggleBookmarkArticle, updateArticleState, updateArticleSensitive, deleteTags, diff --git a/src/mutations/article/toggleSubscribeArticle.ts b/src/mutations/article/toggleBookmarkArticle.ts similarity index 94% rename from src/mutations/article/toggleSubscribeArticle.ts rename to src/mutations/article/toggleBookmarkArticle.ts index f8c0fe151..4f48851ff 100644 --- a/src/mutations/article/toggleSubscribeArticle.ts +++ b/src/mutations/article/toggleBookmarkArticle.ts @@ -14,7 +14,7 @@ import { } from 'common/errors' import { fromGlobalId } from 'common/utils' -const resolver: GQLMutationResolvers['toggleSubscribeArticle'] = async ( +const resolver: GQLMutationResolvers['toggleBookmarkArticle'] = async ( _, { input: { id, enabled } }, { viewer, dataSources: { atomService, articleService, notificationService } } @@ -29,7 +29,7 @@ const resolver: GQLMutationResolvers['toggleSubscribeArticle'] = async ( } const { id: dbId } = fromGlobalId(id) - // banned and archived articles shall still be able to be unsubscribed + // banned and archived articles shall still be able to be unbookmarked const article = enabled === false ? await atomService.findFirst({ diff --git a/src/mutations/user/index.ts b/src/mutations/user/index.ts index 6e6156331..8767f29ca 100644 --- a/src/mutations/user/index.ts +++ b/src/mutations/user/index.ts @@ -20,7 +20,7 @@ import setPassword from './setPassword' import setUserName from './setUserName' import { socialLogin, addSocialLogin, removeSocialLogin } from './socialLogin' import toggleBlockUser from './toggleBlockUser' -import toggleFollowTag from './toggleFollowTag' +import toggleBookmarkTag from './toggleBookmarkTag' import toggleFollowUser from './toggleFollowUser' import toggleUsersBadge from './toggleUsersBadge' import unbindLikerId from './unbindLikerId' @@ -49,7 +49,8 @@ export default { updateNotificationSetting, setCurrency, toggleBlockUser, - toggleFollowTag, + toggleBookmarkTag, + toggleFollowTag: toggleBookmarkTag, toggleFollowUser, clearReadHistory, clearSearchHistory, diff --git a/src/mutations/user/toggleFollowTag.ts b/src/mutations/user/toggleBookmarkTag.ts similarity index 94% rename from src/mutations/user/toggleFollowTag.ts rename to src/mutations/user/toggleBookmarkTag.ts index d71608f29..11d8a8d07 100644 --- a/src/mutations/user/toggleFollowTag.ts +++ b/src/mutations/user/toggleBookmarkTag.ts @@ -4,7 +4,7 @@ import { CACHE_KEYWORD, NODE_TYPES, TAG_ACTION } from 'common/enums' import { ForbiddenError, TagNotFoundError } from 'common/errors' import { fromGlobalId } from 'common/utils' -const resolver: GQLMutationResolvers['toggleFollowTag'] = async ( +const resolver: GQLMutationResolvers['toggleBookmarkTag'] = async ( _, { input: { id, enabled } }, { viewer, dataSources: { tagService, atomService } } diff --git a/src/queries/article/subscribed.ts b/src/queries/article/bookmarked.ts similarity index 73% rename from src/queries/article/subscribed.ts rename to src/queries/article/bookmarked.ts index 15bc220af..39837e2b6 100644 --- a/src/queries/article/subscribed.ts +++ b/src/queries/article/bookmarked.ts @@ -2,7 +2,7 @@ import type { GQLArticleResolvers } from 'definitions' import { USER_ACTION } from 'common/enums' -const resolver: GQLArticleResolvers['subscribed'] = async ( +const resolver: GQLArticleResolvers['bookmarked'] = async ( { id: articleId }, _, { viewer, dataSources: { atomService } } @@ -11,7 +11,7 @@ const resolver: GQLArticleResolvers['subscribed'] = async ( return false } - const subscribedCount = await atomService.count({ + const bookmarkedCount = await atomService.count({ table: 'action_article', where: { userId: viewer.id, @@ -20,7 +20,7 @@ const resolver: GQLArticleResolvers['subscribed'] = async ( }, }) - return subscribedCount > 0 + return bookmarkedCount > 0 } export default resolver diff --git a/src/queries/article/index.ts b/src/queries/article/index.ts index 78e9c0c8f..f37ecd20b 100644 --- a/src/queries/article/index.ts +++ b/src/queries/article/index.ts @@ -10,6 +10,7 @@ import appreciationsReceivedTotal from './appreciationsReceivedTotal' import assets from './assets' import author from './author' import availableTranslations from './availableTranslations' +import bookmarked from './bookmarked' import campaigns from './campaigns' import canComment from './canComment' import canSuperLike from './canSuperLike' @@ -45,8 +46,6 @@ import sensitiveByAuthor from './sensitiveByAuthor' import shortHash from './shortHash' import slug from './slug' import state from './state' -import subscribed from './subscribed' -import subscribers from './subscribers' import summary from './summary' import summaryCustomized from './summaryCustomized' import tagArticles from './tag/articles' @@ -102,8 +101,8 @@ const schema: GQLResolvers = { shortHash, state, pinned, - subscribed, - subscribers, + subscribed: bookmarked, + bookmarked, tags, translation: articleTranslation, availableTranslations, diff --git a/src/queries/article/subscribers.ts b/src/queries/article/subscribers.ts deleted file mode 100644 index 875e6f620..000000000 --- a/src/queries/article/subscribers.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { GQLArticleResolvers } from 'definitions' - -import { USER_ACTION } from 'common/enums' -import { connectionFromPromisedArray, fromConnectionArgs } from 'common/utils' - -const resolver: GQLArticleResolvers['subscribers'] = async ( - { id: articleId }, - { input }, - { - dataSources: { - articleService, - atomService, - connections: { knex }, - }, - } -) => { - const { take, skip } = fromConnectionArgs(input) - - const [countRecord, actions] = await Promise.all([ - knex('action_article') - .where({ targetId: articleId, action: USER_ACTION.subscribe }) - .countDistinct('user_id') - .first(), - articleService.findSubscriptions({ id: articleId, skip, take }), - ]) - - const totalCount = parseInt( - countRecord ? (countRecord.count as string) : '0', - 10 - ) - - return connectionFromPromisedArray( - atomService.userIdLoader.loadMany( - actions.map(({ userId }: { userId: string }) => userId) - ), - input, - totalCount - ) -} - -export default resolver diff --git a/src/queries/user/recommendation/tags.ts b/src/queries/user/recommendation/tags.ts index 46517f577..25aab2e81 100644 --- a/src/queries/user/recommendation/tags.ts +++ b/src/queries/user/recommendation/tags.ts @@ -2,8 +2,6 @@ import type { GQLRecommendationResolvers } from 'definitions' import { chunk } from 'lodash' -import { VIEW } from 'common/enums' -// import { environment } from 'common/environment' import { ForbiddenError } from 'common/errors' import { connectionFromPromisedArray, fromConnectionArgs } from 'common/utils' @@ -43,10 +41,7 @@ export const tags: GQLRecommendationResolvers['tags'] = async ( ) } - const totalCount = await tagService.baseCount( - undefined, // where - VIEW.tags_lasts_view - ) + const totalCount = await tagService.countTopTags() const items = await tagService.findTopTags({ skip, take, diff --git a/src/types/__test__/2/user.test.ts b/src/types/__test__/2/user.test.ts index 83b89da8b..aac172240 100644 --- a/src/types/__test__/2/user.test.ts +++ b/src/types/__test__/2/user.test.ts @@ -1032,7 +1032,6 @@ describe('user recommendations', () => { test('retrieve tags from tags', async () => { await refreshView(MATERIALIZED_VIEW.curation_tag_materialized, knex) - // await refreshView(MATERIALIZED_VIEW.tag_count_materialized, knex) const serverNew = await testClient({ isAuth: true, diff --git a/src/types/article.ts b/src/types/article.ts index 4707f0c97..dbfae62e3 100644 --- a/src/types/article.ts +++ b/src/types/article.ts @@ -18,8 +18,9 @@ export default /* GraphQL */ ` "Edit an article." editArticle(input: EditArticleInput!): Article! @auth(mode: "${AUTH_MODE.oauth}", group: "${SCOPE_GROUP.level3}") @purgeCache(type: "${NODE_TYPES.Article}") - "Subscribe or Unsubscribe article" - toggleSubscribeArticle(input: ToggleItemInput!): Article! @auth(mode: "${AUTH_MODE.oauth}", group: "${SCOPE_GROUP.level1}") @purgeCache(type: "${NODE_TYPES.Article}") + "Bookmark or unbookmark article" + toggleSubscribeArticle(input: ToggleItemInput!): Article! @auth(mode: "${AUTH_MODE.oauth}", group: "${SCOPE_GROUP.level1}") @purgeCache(type: "${NODE_TYPES.Article}") @deprecated(reason: "Use toggleBookmarkArticle instead") + toggleBookmarkArticle(input: ToggleItemInput!): Article! @auth(mode: "${AUTH_MODE.oauth}", group: "${SCOPE_GROUP.level1}") @purgeCache(type: "${NODE_TYPES.Article}") "Appreciate an article." appreciateArticle(input: AppreciateArticleInput!): Article! @auth(mode: "${AUTH_MODE.oauth}", group: "${SCOPE_GROUP.level3}") @purgeCache(type: "${NODE_TYPES.Article}") @rateLimit(limit:5, period:60) @@ -31,9 +32,9 @@ export default /* GraphQL */ ` ############## # Tag # ############## - "Follow or unfollow tag." - toggleFollowTag(input: ToggleItemInput!): Tag! @auth(mode: "${AUTH_MODE.oauth}", group: "${SCOPE_GROUP.level1}") @purgeCache(type: "${NODE_TYPES.Tag}") - + "Bookmark or unbookmark tag." + toggleFollowTag(input: ToggleItemInput!): Tag! @auth(mode: "${AUTH_MODE.oauth}", group: "${SCOPE_GROUP.level1}") @purgeCache(type: "${NODE_TYPES.Tag}") @deprecated(reason: "Use toggleBookmarkTag instead") + toggleBookmarkTag(input: ToggleItemInput!): Tag! @auth(mode: "${AUTH_MODE.oauth}", group: "${SCOPE_GROUP.level1}") @purgeCache(type: "${NODE_TYPES.Tag}") ############## # OSS # @@ -136,9 +137,6 @@ export default /* GraphQL */ ` "Total number of readers of this article." readerCount: Int! @cacheControl(maxAge: ${CACHE_TTL.SHORT}) - "Subscribers of this article." - subscribers(input: ConnectionArgs!): UserConnection! @complexity(multipliers: ["input.first"], value: 1) - "Limit the nuhmber of appreciate per user." appreciateLimit: Int! @@ -151,8 +149,9 @@ export default /* GraphQL */ ` "This value determines if current viewer can SuperLike or not." canSuperLike: Boolean! - "This value determines if current Viewer has subscribed of not." - subscribed: Boolean! + "This value determines if current Viewer has bookmarked of not." + subscribed: Boolean! @deprecated(reason: "Use bookmarked instead") + bookmarked: Boolean! "This value determines if this article is an author selected article or not." pinned: Boolean!