Skip to content

Commit ff7d9cd

Browse files
committed
feat(category): enrich detail responses with summary/tags/pin/count and tagsSum
Expand findCategoryPost select fields and sort by pin first; rewrite findArticleWithTag mapping to surface the same enriched fields with a typed return shape. Add a new getCategoryTagsSum aggregation and attach the result to GET /categories/:slug, fetched in parallel with children. api-client types follow with CategoryChildPost / TagDetailPost and an optional tagsSum field on CategoryWithChildrenModel; fixtures updated. Backs design: Yohaku/docs/superpowers/specs/2026-04-30-category-tag-detail-design.md
1 parent b449ee1 commit ff7d9cd

5 files changed

Lines changed: 142 additions & 18 deletions

File tree

apps/core/src/modules/category/category.controller.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,20 @@ export class CategoryController {
123123
throw new CannotFindException()
124124
}
125125

126-
let children: any[] =
127-
(await this.categoryService.findCategoryPost(res._id.toHexString(), {
126+
const [postsResult, tagsSum] = await Promise.all([
127+
this.categoryService.findCategoryPost(res._id.toHexString(), {
128128
$and: [tag ? { tags: tag } : {}],
129-
})) || []
129+
}),
130+
this.categoryService.getCategoryTagsSum(res._id.toHexString()),
131+
])
132+
133+
let children: any[] = postsResult || []
130134

131135
if (lang && children.length) {
132136
children = await this.translatePostTitles(children, lang)
133137
}
134138

135-
return { data: { ...res, children } }
139+
return { data: { ...res, children, tagsSum } }
136140
}
137141

138142
private translatePostTitles(posts: any[], lang: string) {

apps/core/src/modules/category/category.service.ts

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ModuleRef } from '@nestjs/core'
33
import type { DocumentType, ReturnModelType } from '@typegoose/typegoose'
44
import { omit } from 'es-toolkit/compat'
55
import type { QueryFilter } from 'mongoose'
6+
import { Types } from 'mongoose'
67

78
import { BizException } from '~/common/exceptions/biz.exception'
89
import { CannotFindException } from '~/common/exceptions/cant-find.exception'
@@ -21,6 +22,19 @@ import type { PostService } from '../post/post.service'
2122
import { SlugTrackerService } from '../slug-tracker/slug-tracker.service'
2223
import { CategoryModel, CategoryType } from './category.model'
2324

25+
type TagDetailMapped = {
26+
_id: Types.ObjectId
27+
title: string
28+
slug: string
29+
category: Record<string, unknown>
30+
created?: Date
31+
modified?: Date | null
32+
summary?: string | null
33+
tags?: string[]
34+
pin?: Date | null
35+
count?: { read?: number; like?: number }
36+
}
37+
2438
@Injectable()
2539
export class CategoryService implements OnApplicationBootstrap {
2640
private postService: PostService
@@ -91,10 +105,24 @@ export class CategoryService implements OnApplicationBootstrap {
91105
return data
92106
}
93107

108+
async getCategoryTagsSum(categoryId: string) {
109+
const data = await this.postService.model.aggregate([
110+
{
111+
$match: { categoryId: Types.ObjectId.createFromHexString(categoryId) },
112+
},
113+
{ $project: { tags: 1 } },
114+
{ $unwind: '$tags' },
115+
{ $group: { _id: '$tags', count: { $sum: 1 } } },
116+
{ $project: { _id: 0, name: '$_id', count: 1 } },
117+
{ $sort: { count: -1, name: 1 } },
118+
])
119+
return data as Array<{ name: string; count: number }>
120+
}
121+
94122
async findArticleWithTag(
95123
tag: string,
96124
condition: QueryFilter<DocumentType<PostModel>> = {},
97-
): Promise<null | any[]> {
125+
): Promise<TagDetailMapped[]> {
98126
const posts = await this.postService.model
99127
.find(
100128
{
@@ -108,13 +136,31 @@ export class CategoryService implements OnApplicationBootstrap {
108136
if (posts.length === 0) {
109137
throw new CannotFindException()
110138
}
111-
return posts.map(({ _id, title, slug, category, created }) => ({
112-
_id,
113-
title,
114-
slug,
115-
category: omit(category, ['count', '__v', 'created', 'modified']),
116-
created,
117-
}))
139+
return posts.map(
140+
({
141+
_id,
142+
title,
143+
slug,
144+
category,
145+
created,
146+
modified,
147+
summary,
148+
tags,
149+
pin,
150+
count,
151+
}) => ({
152+
_id,
153+
title,
154+
slug,
155+
category: omit(category, ['count', '__v', 'created', 'modified']),
156+
created,
157+
modified,
158+
summary,
159+
tags,
160+
pin,
161+
count: count ? { read: count.read, like: count.like } : undefined,
162+
}),
163+
)
118164
}
119165

120166
async findCategoryPost(categoryId: string, condition: any = {}) {
@@ -123,8 +169,9 @@ export class CategoryService implements OnApplicationBootstrap {
123169
categoryId,
124170
...condition,
125171
})
126-
.select('title created slug _id')
127-
.sort({ created: -1 })
172+
.select('title slug created modified summary tags pin count images')
173+
.sort({ pin: -1, created: -1 })
174+
.lean()
128175
}
129176

130177
async findPostsInCategory(id: string) {

packages/api-client/__tests__/controllers/category.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import camelcaseKeys from 'camelcase-keys'
2+
13
import { mockRequestInstance } from '~/__tests__/helpers/instance'
24
import { mockResponse } from '~/__tests__/helpers/response'
35
import { CategoryController } from '~/controllers'
4-
import camelcaseKeys from 'camelcase-keys'
56

67
describe('test Category client', () => {
78
const client = mockRequestInstance(CategoryController)
@@ -41,14 +42,31 @@ describe('test Category client', () => {
4142
title: 'pageproxy,为 spa 提供初始数据注入',
4243
slug: 'pageproxy-spa-inject',
4344
created: '2021-08-14T04:37:29.880Z',
45+
modified: '2021-08-14T04:37:29.880Z',
46+
summary: 'A short summary',
47+
tags: ['spa', 'inject'],
48+
pin: null,
49+
count: { read: 100, like: 5 },
50+
images: [],
4451
},
4552
{
4653
id: '60cffff50ec52e0349cbb29f',
4754
title: '曲折的 Vue 3 重构后台之路',
4855
slug: 'mx-space-vue-3',
4956
created: '2021-06-21T02:56:53.126Z',
57+
modified: '2021-06-21T02:56:53.126Z',
58+
summary: 'Refactor journey',
59+
tags: ['vue3'],
60+
pin: 'pin-id',
61+
count: { read: 200, like: 12 },
62+
images: [],
5063
},
5164
],
65+
tagsSum: [
66+
{ name: 'spa', count: 1 },
67+
{ name: 'inject', count: 1 },
68+
{ name: 'vue3', count: 1 },
69+
],
5270
},
5371
})
5472

@@ -72,14 +90,31 @@ describe('test Category client', () => {
7290
title: 'pageproxy,为 spa 提供初始数据注入',
7391
slug: 'pageproxy-spa-inject',
7492
created: '2021-08-14T04:37:29.880Z',
93+
modified: '2021-08-14T04:37:29.880Z',
94+
summary: 'A short summary',
95+
tags: ['spa', 'inject'],
96+
pin: null,
97+
count: { read: 100, like: 5 },
98+
images: [],
7599
},
76100
{
77101
id: '60cffff50ec52e0349cbb29f',
78102
title: '曲折的 Vue 3 重构后台之路',
79103
slug: 'mx-space-vue-3',
80104
created: '2021-06-21T02:56:53.126Z',
105+
modified: '2021-06-21T02:56:53.126Z',
106+
summary: 'Refactor journey',
107+
tags: ['vue3'],
108+
pin: 'pin-id',
109+
count: { read: 200, like: 12 },
110+
images: [],
81111
},
82112
],
113+
tagsSum: [
114+
{ name: 'spa', count: 1 },
115+
{ name: 'inject', count: 1 },
116+
{ name: 'vue3', count: 1 },
117+
],
83118
},
84119
})
85120

@@ -106,6 +141,11 @@ describe('test Category client', () => {
106141
slug: 'programming',
107142
},
108143
created: '2021-04-18T09:33:33.271Z',
144+
modified: '2021-04-18T09:33:33.271Z',
145+
summary: 'A summary',
146+
tags: ['react', 'scroll'],
147+
pin: null,
148+
count: { read: 80, like: 3 },
109149
},
110150
],
111151
})

packages/api-client/controllers/category.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,17 @@ import type {
77
} from '~/interfaces/request'
88
import { attachRawFromOneToAnthor, destructureData } from '~/utils'
99
import { autoBind } from '~/utils/auto-bind'
10+
1011
import type { HTTPClient } from '../core/client'
1112
import { RequestError } from '../core/error'
1213
import type {
1314
CategoryEntries,
1415
CategoryModel,
1516
CategoryWithChildrenModel,
17+
TagDetailPost,
1618
TagModel,
1719
} from '../models/category'
1820
import { CategoryType } from '../models/category'
19-
import type { PostModel } from '../models/post'
2021

2122
declare module '../core/client' {
2223
interface HTTPClient<
@@ -106,7 +107,7 @@ export class CategoryController<ResponseWrapper> implements IController {
106107
async getTagByName(name: string) {
107108
const res = await this.proxy(name).get<{
108109
tag: string
109-
data: Pick<PostModel, 'id' | 'title' | 'slug' | 'category' | 'created'>[]
110+
data: TagDetailPost[]
110111
}>({
111112
params: {
112113
tag: 1,

packages/api-client/models/category.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,46 @@ export interface CategoryModel extends BaseModel {
1212
slug: string
1313
name: string
1414
}
15+
16+
export type CategoryChildPost = Pick<
17+
PostModel,
18+
| 'id'
19+
| 'title'
20+
| 'slug'
21+
| 'modified'
22+
| 'created'
23+
| 'summary'
24+
| 'tags'
25+
| 'pin'
26+
| 'count'
27+
| 'images'
28+
>
29+
1530
export type CategoryWithChildrenModel = CategoryModel & {
16-
children: Pick<PostModel, 'id' | 'title' | 'slug' | 'modified' | 'created'>[]
31+
children: CategoryChildPost[]
32+
/** Aggregated tag-name → post-count for posts under this category. */
33+
tagsSum?: Array<{ name: string; count: number }>
1734
}
1835

1936
export type CategoryEntries = {
2037
entries: Record<string, CategoryWithChildrenModel>
2138
}
39+
2240
export interface TagModel {
2341
count: number
2442
name: string
2543
}
44+
45+
export type TagDetailPost = Pick<
46+
PostModel,
47+
| 'id'
48+
| 'title'
49+
| 'slug'
50+
| 'category'
51+
| 'created'
52+
| 'modified'
53+
| 'summary'
54+
| 'tags'
55+
| 'pin'
56+
| 'count'
57+
>

0 commit comments

Comments
 (0)