@rytass/cms-base-nestjs-graphql-module 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1015 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,3 +1,1016 @@
|
|
|
1
|
-
# CMS Base
|
|
1
|
+
# Rytass Utils - CMS Base NestJS GraphQL Module
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Comprehensive GraphQL API layer for the CMS Base NestJS Module, providing a complete content management system with GraphQL queries, mutations, and resolvers. Features multi-language support, approval workflows, version control, and optimized DataLoader patterns for high-performance content delivery.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
### Core GraphQL API
|
|
8
|
+
- [x] Complete GraphQL schema for articles and categories
|
|
9
|
+
- [x] Public and backstage (admin) API endpoints
|
|
10
|
+
- [x] Multi-language content delivery
|
|
11
|
+
- [x] Real-time content queries with filters
|
|
12
|
+
- [x] Paginated collections with metadata
|
|
13
|
+
|
|
14
|
+
### Advanced Content Management
|
|
15
|
+
- [x] Article approval workflow resolvers
|
|
16
|
+
- [x] Version control and draft management
|
|
17
|
+
- [x] Category hierarchical relationships
|
|
18
|
+
- [x] Custom field support in GraphQL
|
|
19
|
+
- [x] Full-text search integration
|
|
20
|
+
|
|
21
|
+
### Performance Optimization
|
|
22
|
+
- [x] DataLoader pattern for N+1 query prevention
|
|
23
|
+
- [x] Member data caching with LRU cache
|
|
24
|
+
- [x] Article relationship optimization
|
|
25
|
+
- [x] Efficient batch loading for categories
|
|
26
|
+
- [x] Query complexity analysis
|
|
27
|
+
|
|
28
|
+
### Integration Features
|
|
29
|
+
- [x] Member system integration (@rytass/member-base-nestjs-module)
|
|
30
|
+
- [x] Permission-based access control
|
|
31
|
+
- [x] Quadrats rich content editor support
|
|
32
|
+
- [x] Multi-language decorator system
|
|
33
|
+
- [x] TypeORM entity relationships
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install @rytass/cms-base-nestjs-graphql-module @rytass/cms-base-nestjs-module
|
|
39
|
+
# Peer dependencies
|
|
40
|
+
npm install @nestjs/common @nestjs/typeorm @nestjs/graphql typeorm
|
|
41
|
+
# or
|
|
42
|
+
yarn add @rytass/cms-base-nestjs-graphql-module @rytass/cms-base-nestjs-module
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Basic Setup
|
|
46
|
+
|
|
47
|
+
### Module Configuration
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// app.module.ts
|
|
51
|
+
import { Module } from '@nestjs/common';
|
|
52
|
+
import { GraphQLModule } from '@nestjs/graphql';
|
|
53
|
+
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
|
54
|
+
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
55
|
+
import { CMSBaseGraphQLModule } from '@rytass/cms-base-nestjs-graphql-module';
|
|
56
|
+
|
|
57
|
+
@Module({
|
|
58
|
+
imports: [
|
|
59
|
+
TypeOrmModule.forRoot({
|
|
60
|
+
type: 'postgres',
|
|
61
|
+
host: 'localhost',
|
|
62
|
+
port: 5432,
|
|
63
|
+
username: 'username',
|
|
64
|
+
password: 'password',
|
|
65
|
+
database: 'cms_database',
|
|
66
|
+
autoLoadEntities: true,
|
|
67
|
+
synchronize: true, // Development only
|
|
68
|
+
}),
|
|
69
|
+
GraphQLModule.forRoot<ApolloDriverConfig>({
|
|
70
|
+
driver: ApolloDriver,
|
|
71
|
+
autoSchemaFile: 'schema.gql',
|
|
72
|
+
sortSchema: true,
|
|
73
|
+
playground: true,
|
|
74
|
+
introspection: true,
|
|
75
|
+
}),
|
|
76
|
+
CMSBaseGraphQLModule.forRoot({
|
|
77
|
+
multipleLanguageMode: true,
|
|
78
|
+
draftMode: true,
|
|
79
|
+
signatureLevels: [
|
|
80
|
+
{ id: 1, name: 'Editor', level: 1 },
|
|
81
|
+
{ id: 2, name: 'Senior Editor', level: 2 },
|
|
82
|
+
{ id: 3, name: 'Chief Editor', level: 3 }
|
|
83
|
+
],
|
|
84
|
+
fullTextSearchMode: true,
|
|
85
|
+
autoReleaseAfterApproved: false
|
|
86
|
+
})
|
|
87
|
+
],
|
|
88
|
+
})
|
|
89
|
+
export class AppModule {}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Async Configuration
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
// app.module.ts
|
|
96
|
+
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
97
|
+
import { CMSBaseGraphQLModule } from '@rytass/cms-base-nestjs-graphql-module';
|
|
98
|
+
|
|
99
|
+
@Module({
|
|
100
|
+
imports: [
|
|
101
|
+
ConfigModule.forRoot(),
|
|
102
|
+
CMSBaseGraphQLModule.forRootAsync({
|
|
103
|
+
imports: [ConfigModule],
|
|
104
|
+
inject: [ConfigService],
|
|
105
|
+
useFactory: (configService: ConfigService) => ({
|
|
106
|
+
multipleLanguageMode: configService.get('CMS_MULTI_LANGUAGE') === 'true',
|
|
107
|
+
draftMode: configService.get('CMS_DRAFT_MODE') === 'true',
|
|
108
|
+
signatureLevels: JSON.parse(configService.get('CMS_SIGNATURE_LEVELS') || '[]'),
|
|
109
|
+
fullTextSearchMode: configService.get('CMS_FULL_TEXT_SEARCH') === 'true',
|
|
110
|
+
autoReleaseAfterApproved: configService.get('CMS_AUTO_RELEASE') === 'true'
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
],
|
|
114
|
+
})
|
|
115
|
+
export class AppModule {}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## GraphQL API Usage
|
|
119
|
+
|
|
120
|
+
### Public Queries
|
|
121
|
+
|
|
122
|
+
#### Query Single Article
|
|
123
|
+
|
|
124
|
+
```graphql
|
|
125
|
+
query GetArticle($id: ID!, $language: String) {
|
|
126
|
+
article(id: $id) {
|
|
127
|
+
articleId
|
|
128
|
+
title
|
|
129
|
+
description
|
|
130
|
+
content
|
|
131
|
+
publishedAt
|
|
132
|
+
releasedBy {
|
|
133
|
+
id
|
|
134
|
+
username
|
|
135
|
+
email
|
|
136
|
+
}
|
|
137
|
+
categories {
|
|
138
|
+
id
|
|
139
|
+
name
|
|
140
|
+
slug
|
|
141
|
+
parentCategory {
|
|
142
|
+
id
|
|
143
|
+
name
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
// Apollo Client usage
|
|
152
|
+
import { gql, useQuery } from '@apollo/client';
|
|
153
|
+
|
|
154
|
+
const GET_ARTICLE = gql`
|
|
155
|
+
query GetArticle($id: ID!) {
|
|
156
|
+
article(id: $id) {
|
|
157
|
+
articleId
|
|
158
|
+
title
|
|
159
|
+
description
|
|
160
|
+
content
|
|
161
|
+
publishedAt
|
|
162
|
+
categories {
|
|
163
|
+
id
|
|
164
|
+
name
|
|
165
|
+
slug
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
`;
|
|
170
|
+
|
|
171
|
+
function ArticlePage({ articleId }: { articleId: string }) {
|
|
172
|
+
const { loading, error, data } = useQuery(GET_ARTICLE, {
|
|
173
|
+
variables: { id: articleId }
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (loading) return <div>Loading...</div>;
|
|
177
|
+
if (error) return <div>Error: {error.message}</div>;
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<article>
|
|
181
|
+
<h1>{data.article.title}</h1>
|
|
182
|
+
<p>{data.article.description}</p>
|
|
183
|
+
{/* Render Quadrats content */}
|
|
184
|
+
</article>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
#### Query Article Collection
|
|
190
|
+
|
|
191
|
+
```graphql
|
|
192
|
+
query GetArticles(
|
|
193
|
+
$page: Int
|
|
194
|
+
$limit: Int
|
|
195
|
+
$categoryId: ID
|
|
196
|
+
$search: String
|
|
197
|
+
$language: String
|
|
198
|
+
) {
|
|
199
|
+
articles(
|
|
200
|
+
page: $page
|
|
201
|
+
limit: $limit
|
|
202
|
+
categoryId: $categoryId
|
|
203
|
+
search: $search
|
|
204
|
+
) {
|
|
205
|
+
items {
|
|
206
|
+
articleId
|
|
207
|
+
title
|
|
208
|
+
description
|
|
209
|
+
publishedAt
|
|
210
|
+
categories {
|
|
211
|
+
id
|
|
212
|
+
name
|
|
213
|
+
slug
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
meta {
|
|
217
|
+
totalCount
|
|
218
|
+
pageCount
|
|
219
|
+
currentPage
|
|
220
|
+
hasNextPage
|
|
221
|
+
hasPreviousPage
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
// React component with pagination
|
|
229
|
+
import { gql, useQuery } from '@apollo/client';
|
|
230
|
+
|
|
231
|
+
const GET_ARTICLES = gql`
|
|
232
|
+
query GetArticles($page: Int, $limit: Int, $categoryId: ID) {
|
|
233
|
+
articles(page: $page, limit: $limit, categoryId: $categoryId) {
|
|
234
|
+
items {
|
|
235
|
+
articleId
|
|
236
|
+
title
|
|
237
|
+
description
|
|
238
|
+
publishedAt
|
|
239
|
+
}
|
|
240
|
+
meta {
|
|
241
|
+
totalCount
|
|
242
|
+
currentPage
|
|
243
|
+
hasNextPage
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
`;
|
|
248
|
+
|
|
249
|
+
function ArticleList() {
|
|
250
|
+
const [page, setPage] = useState(1);
|
|
251
|
+
const { loading, error, data } = useQuery(GET_ARTICLES, {
|
|
252
|
+
variables: { page, limit: 10 }
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<div>
|
|
257
|
+
{data?.articles.items.map((article) => (
|
|
258
|
+
<ArticleCard key={article.articleId} article={article} />
|
|
259
|
+
))}
|
|
260
|
+
|
|
261
|
+
<Pagination
|
|
262
|
+
currentPage={data?.articles.meta.currentPage}
|
|
263
|
+
hasNextPage={data?.articles.meta.hasNextPage}
|
|
264
|
+
onPageChange={setPage}
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Backstage (Admin) Queries
|
|
272
|
+
|
|
273
|
+
#### Query Articles with Management Data
|
|
274
|
+
|
|
275
|
+
```graphql
|
|
276
|
+
query GetBackstageArticles(
|
|
277
|
+
$page: Int
|
|
278
|
+
$limit: Int
|
|
279
|
+
$stage: String
|
|
280
|
+
$authorId: ID
|
|
281
|
+
) {
|
|
282
|
+
backstageArticles(
|
|
283
|
+
page: $page
|
|
284
|
+
limit: $limit
|
|
285
|
+
stage: $stage
|
|
286
|
+
authorId: $authorId
|
|
287
|
+
) {
|
|
288
|
+
items {
|
|
289
|
+
articleId
|
|
290
|
+
title
|
|
291
|
+
stage
|
|
292
|
+
createdAt
|
|
293
|
+
updatedAt
|
|
294
|
+
author {
|
|
295
|
+
id
|
|
296
|
+
username
|
|
297
|
+
}
|
|
298
|
+
signature {
|
|
299
|
+
id
|
|
300
|
+
approved
|
|
301
|
+
approvedAt
|
|
302
|
+
level
|
|
303
|
+
reviewer {
|
|
304
|
+
id
|
|
305
|
+
username
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
meta {
|
|
310
|
+
totalCount
|
|
311
|
+
pageCount
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
#### Query Article Versions
|
|
318
|
+
|
|
319
|
+
```graphql
|
|
320
|
+
query GetArticleVersions($articleId: ID!) {
|
|
321
|
+
backstageArticle(id: $articleId) {
|
|
322
|
+
articleId
|
|
323
|
+
title
|
|
324
|
+
stage
|
|
325
|
+
versions {
|
|
326
|
+
id
|
|
327
|
+
versionNumber
|
|
328
|
+
createdAt
|
|
329
|
+
author {
|
|
330
|
+
username
|
|
331
|
+
}
|
|
332
|
+
changes {
|
|
333
|
+
field
|
|
334
|
+
oldValue
|
|
335
|
+
newValue
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### Mutations
|
|
343
|
+
|
|
344
|
+
#### Create Article
|
|
345
|
+
|
|
346
|
+
```graphql
|
|
347
|
+
mutation CreateArticle($input: CreateArticleInput!) {
|
|
348
|
+
createArticle(input: $input) {
|
|
349
|
+
articleId
|
|
350
|
+
title
|
|
351
|
+
stage
|
|
352
|
+
createdAt
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
// Apollo Client mutation
|
|
359
|
+
import { gql, useMutation } from '@apollo/client';
|
|
360
|
+
|
|
361
|
+
const CREATE_ARTICLE = gql`
|
|
362
|
+
mutation CreateArticle($input: CreateArticleInput!) {
|
|
363
|
+
createArticle(input: $input) {
|
|
364
|
+
articleId
|
|
365
|
+
title
|
|
366
|
+
stage
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
`;
|
|
370
|
+
|
|
371
|
+
function CreateArticleForm() {
|
|
372
|
+
const [createArticle, { loading, error }] = useMutation(CREATE_ARTICLE);
|
|
373
|
+
|
|
374
|
+
const handleSubmit = async (formData: any) => {
|
|
375
|
+
try {
|
|
376
|
+
const { data } = await createArticle({
|
|
377
|
+
variables: {
|
|
378
|
+
input: {
|
|
379
|
+
title: {
|
|
380
|
+
'en-US': formData.titleEn,
|
|
381
|
+
'zh-TW': formData.titleZh
|
|
382
|
+
},
|
|
383
|
+
content: formData.content,
|
|
384
|
+
categoryIds: formData.categories,
|
|
385
|
+
customFields: formData.customFields
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
console.log('Article created:', data.createArticle);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
console.error('Error creating article:', err);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
return (
|
|
397
|
+
<form onSubmit={handleSubmit}>
|
|
398
|
+
{/* Form fields */}
|
|
399
|
+
</form>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
#### Update Article
|
|
405
|
+
|
|
406
|
+
```graphql
|
|
407
|
+
mutation UpdateArticle($id: ID!, $input: UpdateArticleInput!) {
|
|
408
|
+
updateArticle(id: $id, input: $input) {
|
|
409
|
+
articleId
|
|
410
|
+
title
|
|
411
|
+
stage
|
|
412
|
+
updatedAt
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
#### Approve Article
|
|
418
|
+
|
|
419
|
+
```graphql
|
|
420
|
+
mutation ApproveArticle($articleId: ID!, $level: Int!, $comments: String) {
|
|
421
|
+
approveArticle(
|
|
422
|
+
articleId: $articleId
|
|
423
|
+
level: $level
|
|
424
|
+
comments: $comments
|
|
425
|
+
) {
|
|
426
|
+
id
|
|
427
|
+
approved
|
|
428
|
+
approvedAt
|
|
429
|
+
level
|
|
430
|
+
comments
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
## Multi-Language Support
|
|
436
|
+
|
|
437
|
+
### Language Context
|
|
438
|
+
|
|
439
|
+
```typescript
|
|
440
|
+
// Custom decorator for language detection
|
|
441
|
+
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
442
|
+
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
443
|
+
|
|
444
|
+
export const Language = createParamDecorator(
|
|
445
|
+
(data: unknown, context: ExecutionContext) => {
|
|
446
|
+
const ctx = GqlExecutionContext.create(context);
|
|
447
|
+
const request = ctx.getContext().req;
|
|
448
|
+
|
|
449
|
+
// Extract language from headers, query params, or JWT token
|
|
450
|
+
return request.headers['accept-language'] ||
|
|
451
|
+
request.query.language ||
|
|
452
|
+
'en-US';
|
|
453
|
+
}
|
|
454
|
+
);
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
### Multi-Language Queries
|
|
458
|
+
|
|
459
|
+
```graphql
|
|
460
|
+
query GetMultiLanguageArticle($id: ID!) {
|
|
461
|
+
article(id: $id) {
|
|
462
|
+
articleId
|
|
463
|
+
title # Returns title in requested language
|
|
464
|
+
description
|
|
465
|
+
content
|
|
466
|
+
multiLanguageTitle {
|
|
467
|
+
language
|
|
468
|
+
value
|
|
469
|
+
}
|
|
470
|
+
multiLanguageContent {
|
|
471
|
+
language
|
|
472
|
+
value
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
// Frontend language switching
|
|
480
|
+
function ArticleWithLanguage({ articleId }: { articleId: string }) {
|
|
481
|
+
const [language, setLanguage] = useState('en-US');
|
|
482
|
+
|
|
483
|
+
const { data } = useQuery(GET_ARTICLE, {
|
|
484
|
+
variables: { id: articleId },
|
|
485
|
+
context: {
|
|
486
|
+
headers: {
|
|
487
|
+
'Accept-Language': language
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
return (
|
|
493
|
+
<div>
|
|
494
|
+
<LanguageSelector onChange={setLanguage} />
|
|
495
|
+
<article>
|
|
496
|
+
<h1>{data?.article.title}</h1>
|
|
497
|
+
<div>{data?.article.content}</div>
|
|
498
|
+
</article>
|
|
499
|
+
</div>
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
## Advanced Features
|
|
505
|
+
|
|
506
|
+
### Custom Resolvers
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
// custom-article.resolver.ts
|
|
510
|
+
import { Resolver, Query, ResolveField, Parent } from '@nestjs/graphql';
|
|
511
|
+
import { ArticleDto } from '@rytass/cms-base-nestjs-graphql-module';
|
|
512
|
+
|
|
513
|
+
@Resolver(() => ArticleDto)
|
|
514
|
+
export class CustomArticleResolver {
|
|
515
|
+
|
|
516
|
+
@ResolveField(() => String, { nullable: true })
|
|
517
|
+
async seoTitle(@Parent() article: ArticleDto): Promise<string | null> {
|
|
518
|
+
// Custom SEO title logic
|
|
519
|
+
return article.title + ' | Your Site Name';
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
@ResolveField(() => [String])
|
|
523
|
+
async tags(@Parent() article: ArticleDto): Promise<string[]> {
|
|
524
|
+
// Extract tags from content or custom fields
|
|
525
|
+
return article.customFields?.tags || [];
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
@ResolveField(() => Int)
|
|
529
|
+
async readingTime(@Parent() article: ArticleDto): Promise<number> {
|
|
530
|
+
// Calculate reading time based on content
|
|
531
|
+
const wordCount = this.countWords(article.content);
|
|
532
|
+
return Math.ceil(wordCount / 200); // Assuming 200 WPM
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private countWords(content: any[]): number {
|
|
536
|
+
// Implement word counting logic for Quadrats content
|
|
537
|
+
return content.reduce((count, block) => {
|
|
538
|
+
if (block.type === 'text') {
|
|
539
|
+
return count + (block.text?.split(' ').length || 0);
|
|
540
|
+
}
|
|
541
|
+
return count;
|
|
542
|
+
}, 0);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
### DataLoader Optimization
|
|
548
|
+
|
|
549
|
+
```typescript
|
|
550
|
+
// custom-dataloader.service.ts
|
|
551
|
+
import { Injectable } from '@nestjs/common';
|
|
552
|
+
import DataLoader from 'dataloader';
|
|
553
|
+
import { InjectRepository } from '@nestjs/typeorm';
|
|
554
|
+
import { Repository, In } from 'typeorm';
|
|
555
|
+
|
|
556
|
+
@Injectable()
|
|
557
|
+
export class CustomDataLoaderService {
|
|
558
|
+
|
|
559
|
+
// Article view count loader
|
|
560
|
+
public readonly articleViewsLoader = new DataLoader<string, number>(
|
|
561
|
+
async (articleIds: string[]) => {
|
|
562
|
+
const viewCounts = await this.getArticleViewCounts(articleIds);
|
|
563
|
+
return articleIds.map(id => viewCounts[id] || 0);
|
|
564
|
+
}
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
// Related articles loader
|
|
568
|
+
public readonly relatedArticlesLoader = new DataLoader<string, ArticleDto[]>(
|
|
569
|
+
async (articleIds: string[]) => {
|
|
570
|
+
const relatedArticles = await this.getRelatedArticles(articleIds);
|
|
571
|
+
return articleIds.map(id => relatedArticles[id] || []);
|
|
572
|
+
}
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
private async getArticleViewCounts(articleIds: string[]): Promise<Record<string, number>> {
|
|
576
|
+
// Implement batch view count fetching
|
|
577
|
+
return {};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private async getRelatedArticles(articleIds: string[]): Promise<Record<string, ArticleDto[]>> {
|
|
581
|
+
// Implement batch related articles fetching
|
|
582
|
+
return {};
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
### Search Integration
|
|
588
|
+
|
|
589
|
+
```graphql
|
|
590
|
+
query SearchArticles(
|
|
591
|
+
$query: String!
|
|
592
|
+
$filters: SearchFiltersInput
|
|
593
|
+
$page: Int
|
|
594
|
+
$limit: Int
|
|
595
|
+
) {
|
|
596
|
+
searchArticles(
|
|
597
|
+
query: $query
|
|
598
|
+
filters: $filters
|
|
599
|
+
page: $page
|
|
600
|
+
limit: $limit
|
|
601
|
+
) {
|
|
602
|
+
items {
|
|
603
|
+
articleId
|
|
604
|
+
title
|
|
605
|
+
description
|
|
606
|
+
searchScore
|
|
607
|
+
highlights {
|
|
608
|
+
field
|
|
609
|
+
snippets
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
meta {
|
|
613
|
+
totalCount
|
|
614
|
+
searchTime
|
|
615
|
+
suggestions
|
|
616
|
+
}
|
|
617
|
+
facets {
|
|
618
|
+
categories {
|
|
619
|
+
id
|
|
620
|
+
name
|
|
621
|
+
count
|
|
622
|
+
}
|
|
623
|
+
authors {
|
|
624
|
+
id
|
|
625
|
+
name
|
|
626
|
+
count
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
## Permission-Based Access
|
|
634
|
+
|
|
635
|
+
### Role-Based Queries
|
|
636
|
+
|
|
637
|
+
```typescript
|
|
638
|
+
// permissions.resolver.ts
|
|
639
|
+
import { Resolver, Query, UseGuards } from '@nestjs/graphql';
|
|
640
|
+
import { AllowActions, RequireActions } from '@rytass/member-base-nestjs-module';
|
|
641
|
+
import { BaseAction } from './constants/enum/base-action.enum';
|
|
642
|
+
import { BaseResource } from './constants/enum/base-resource.enum';
|
|
643
|
+
|
|
644
|
+
@Resolver()
|
|
645
|
+
export class PermissionResolver {
|
|
646
|
+
|
|
647
|
+
@Query(() => [BackstageArticleDto])
|
|
648
|
+
@RequireActions([BaseAction.READ], BaseResource.ARTICLE)
|
|
649
|
+
async getAllArticles(): Promise<BackstageArticleDto[]> {
|
|
650
|
+
// Only accessible to users with READ permission on ARTICLE resource
|
|
651
|
+
return this.articleService.findAll();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
@Query(() => BackstageArticleDto)
|
|
655
|
+
@RequireActions([BaseAction.UPDATE], BaseResource.ARTICLE)
|
|
656
|
+
async getEditableArticle(@Args('id') id: string): Promise<BackstageArticleDto> {
|
|
657
|
+
// Only accessible to users with UPDATE permission
|
|
658
|
+
return this.articleService.findEditableById(id);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
### Context-Based Security
|
|
664
|
+
|
|
665
|
+
```typescript
|
|
666
|
+
// security.resolver.ts
|
|
667
|
+
import { Resolver, Query, Context } from '@nestjs/graphql';
|
|
668
|
+
|
|
669
|
+
@Resolver()
|
|
670
|
+
export class SecurityResolver {
|
|
671
|
+
|
|
672
|
+
@Query(() => [ArticleDto])
|
|
673
|
+
async getUserArticles(@Context() context: any): Promise<ArticleDto[]> {
|
|
674
|
+
const userId = context.req.user?.id;
|
|
675
|
+
|
|
676
|
+
if (!userId) {
|
|
677
|
+
throw new UnauthorizedException('User not authenticated');
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return this.articleService.findByAuthor(userId);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
## Integration Examples
|
|
686
|
+
|
|
687
|
+
### Frontend Integration (React + Apollo)
|
|
688
|
+
|
|
689
|
+
```typescript
|
|
690
|
+
// ArticleManagement.tsx
|
|
691
|
+
import React from 'react';
|
|
692
|
+
import { useQuery, useMutation } from '@apollo/client';
|
|
693
|
+
import { GET_BACKSTAGE_ARTICLES, APPROVE_ARTICLE } from './queries';
|
|
694
|
+
|
|
695
|
+
export function ArticleManagement() {
|
|
696
|
+
const { data, loading, refetch } = useQuery(GET_BACKSTAGE_ARTICLES, {
|
|
697
|
+
variables: { page: 1, limit: 20, stage: 'PENDING' }
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
const [approveArticle] = useMutation(APPROVE_ARTICLE, {
|
|
701
|
+
onCompleted: () => refetch()
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
const handleApprove = async (articleId: string) => {
|
|
705
|
+
await approveArticle({
|
|
706
|
+
variables: {
|
|
707
|
+
articleId,
|
|
708
|
+
level: 2,
|
|
709
|
+
comments: 'Approved for publication'
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
if (loading) return <div>Loading...</div>;
|
|
715
|
+
|
|
716
|
+
return (
|
|
717
|
+
<div>
|
|
718
|
+
<h2>Pending Articles</h2>
|
|
719
|
+
{data?.backstageArticles.items.map((article: any) => (
|
|
720
|
+
<ArticleCard
|
|
721
|
+
key={article.articleId}
|
|
722
|
+
article={article}
|
|
723
|
+
onApprove={() => handleApprove(article.articleId)}
|
|
724
|
+
/>
|
|
725
|
+
))}
|
|
726
|
+
</div>
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
### Mobile App Integration (React Native + Apollo)
|
|
732
|
+
|
|
733
|
+
```typescript
|
|
734
|
+
// ArticleList.tsx
|
|
735
|
+
import React from 'react';
|
|
736
|
+
import { FlatList, Text, View } from 'react-native';
|
|
737
|
+
import { useQuery } from '@apollo/client';
|
|
738
|
+
import { GET_ARTICLES } from './queries';
|
|
739
|
+
|
|
740
|
+
export function ArticleList() {
|
|
741
|
+
const { data, loading, fetchMore } = useQuery(GET_ARTICLES, {
|
|
742
|
+
variables: { page: 1, limit: 10 }
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
const loadMore = () => {
|
|
746
|
+
if (data?.articles.meta.hasNextPage) {
|
|
747
|
+
fetchMore({
|
|
748
|
+
variables: {
|
|
749
|
+
page: data.articles.meta.currentPage + 1
|
|
750
|
+
},
|
|
751
|
+
updateQuery: (prev, { fetchMoreResult }) => {
|
|
752
|
+
if (!fetchMoreResult) return prev;
|
|
753
|
+
|
|
754
|
+
return {
|
|
755
|
+
articles: {
|
|
756
|
+
...fetchMoreResult.articles,
|
|
757
|
+
items: [
|
|
758
|
+
...prev.articles.items,
|
|
759
|
+
...fetchMoreResult.articles.items
|
|
760
|
+
]
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
return (
|
|
769
|
+
<FlatList
|
|
770
|
+
data={data?.articles.items}
|
|
771
|
+
renderItem={({ item }) => (
|
|
772
|
+
<View>
|
|
773
|
+
<Text>{item.title}</Text>
|
|
774
|
+
<Text>{item.description}</Text>
|
|
775
|
+
</View>
|
|
776
|
+
)}
|
|
777
|
+
onEndReached={loadMore}
|
|
778
|
+
onEndReachedThreshold={0.5}
|
|
779
|
+
/>
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
### Next.js SSR Integration
|
|
785
|
+
|
|
786
|
+
```typescript
|
|
787
|
+
// pages/articles/[id].tsx
|
|
788
|
+
import { GetServerSideProps } from 'next';
|
|
789
|
+
import { ApolloClient, InMemoryCache, gql } from '@apollo/client';
|
|
790
|
+
|
|
791
|
+
export default function ArticlePage({ article }: { article: any }) {
|
|
792
|
+
return (
|
|
793
|
+
<div>
|
|
794
|
+
<h1>{article.title}</h1>
|
|
795
|
+
<div>{article.description}</div>
|
|
796
|
+
{/* Render article content */}
|
|
797
|
+
</div>
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export const getServerSideProps: GetServerSideProps = async ({ params, req }) => {
|
|
802
|
+
const client = new ApolloClient({
|
|
803
|
+
uri: process.env.GRAPHQL_ENDPOINT,
|
|
804
|
+
cache: new InMemoryCache(),
|
|
805
|
+
headers: {
|
|
806
|
+
'Accept-Language': req.headers['accept-language'] || 'en-US'
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
const { data } = await client.query({
|
|
811
|
+
query: gql`
|
|
812
|
+
query GetArticle($id: ID!) {
|
|
813
|
+
article(id: $id) {
|
|
814
|
+
articleId
|
|
815
|
+
title
|
|
816
|
+
description
|
|
817
|
+
content
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
`,
|
|
821
|
+
variables: { id: params?.id }
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
return {
|
|
825
|
+
props: {
|
|
826
|
+
article: data.article
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
};
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
## Performance Optimization
|
|
833
|
+
|
|
834
|
+
### Query Complexity Analysis
|
|
835
|
+
|
|
836
|
+
```typescript
|
|
837
|
+
// complexity.config.ts
|
|
838
|
+
import { createComplexityLimitRule } from 'graphql-query-complexity';
|
|
839
|
+
|
|
840
|
+
export const complexityConfig = {
|
|
841
|
+
maximumComplexity: 1000,
|
|
842
|
+
validators: [createComplexityLimitRule(1000)],
|
|
843
|
+
createError: (max: number, actual: number) => {
|
|
844
|
+
return new Error(`Query is too complex: ${actual}. Maximum allowed complexity: ${max}`);
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
### Caching Strategy
|
|
850
|
+
|
|
851
|
+
```typescript
|
|
852
|
+
// cache.config.ts
|
|
853
|
+
import { CacheModule } from '@nestjs/cache-manager';
|
|
854
|
+
import { redisStore } from 'cache-manager-redis-store';
|
|
855
|
+
|
|
856
|
+
@Module({
|
|
857
|
+
imports: [
|
|
858
|
+
CacheModule.register({
|
|
859
|
+
store: redisStore,
|
|
860
|
+
host: 'localhost',
|
|
861
|
+
port: 6379,
|
|
862
|
+
ttl: 600, // 10 minutes
|
|
863
|
+
})
|
|
864
|
+
]
|
|
865
|
+
})
|
|
866
|
+
export class CacheConfig {}
|
|
867
|
+
|
|
868
|
+
// Cached resolver
|
|
869
|
+
@Resolver()
|
|
870
|
+
export class CachedArticleResolver {
|
|
871
|
+
|
|
872
|
+
@Query(() => [ArticleDto])
|
|
873
|
+
@UseInterceptors(CacheInterceptor)
|
|
874
|
+
@CacheTTL(300) // 5 minutes
|
|
875
|
+
async popularArticles(): Promise<ArticleDto[]> {
|
|
876
|
+
return this.articleService.findPopular();
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
## Testing
|
|
882
|
+
|
|
883
|
+
### GraphQL Testing
|
|
884
|
+
|
|
885
|
+
```typescript
|
|
886
|
+
// article.resolver.spec.ts
|
|
887
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
888
|
+
import { INestApplication } from '@nestjs/common';
|
|
889
|
+
import * as request from 'supertest';
|
|
890
|
+
import { GraphQLModule } from '@nestjs/graphql';
|
|
891
|
+
|
|
892
|
+
describe('Article Resolver (e2e)', () => {
|
|
893
|
+
let app: INestApplication;
|
|
894
|
+
|
|
895
|
+
beforeEach(async () => {
|
|
896
|
+
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
897
|
+
imports: [
|
|
898
|
+
GraphQLModule.forRoot({
|
|
899
|
+
autoSchemaFile: true,
|
|
900
|
+
}),
|
|
901
|
+
CMSBaseGraphQLModule.forRoot({
|
|
902
|
+
multipleLanguageMode: false,
|
|
903
|
+
draftMode: true,
|
|
904
|
+
})
|
|
905
|
+
],
|
|
906
|
+
}).compile();
|
|
907
|
+
|
|
908
|
+
app = moduleFixture.createNestApplication();
|
|
909
|
+
await app.init();
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
it('should get article by id', () => {
|
|
913
|
+
const query = `
|
|
914
|
+
query {
|
|
915
|
+
article(id: "test-id") {
|
|
916
|
+
articleId
|
|
917
|
+
title
|
|
918
|
+
description
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
`;
|
|
922
|
+
|
|
923
|
+
return request(app.getHttpServer())
|
|
924
|
+
.post('/graphql')
|
|
925
|
+
.send({ query })
|
|
926
|
+
.expect(200)
|
|
927
|
+
.expect((res) => {
|
|
928
|
+
expect(res.body.data.article).toBeDefined();
|
|
929
|
+
expect(res.body.data.article.articleId).toBe('test-id');
|
|
930
|
+
});
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
### DataLoader Testing
|
|
936
|
+
|
|
937
|
+
```typescript
|
|
938
|
+
// dataloader.spec.ts
|
|
939
|
+
import { Test } from '@nestjs/testing';
|
|
940
|
+
import { ArticleDataLoader } from '../data-loaders/article.dataloader';
|
|
941
|
+
|
|
942
|
+
describe('ArticleDataLoader', () => {
|
|
943
|
+
let dataLoader: ArticleDataLoader;
|
|
944
|
+
|
|
945
|
+
beforeEach(async () => {
|
|
946
|
+
const module = await Test.createTestingModule({
|
|
947
|
+
providers: [ArticleDataLoader]
|
|
948
|
+
}).compile();
|
|
949
|
+
|
|
950
|
+
dataLoader = module.get<ArticleDataLoader>(ArticleDataLoader);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it('should batch load categories', async () => {
|
|
954
|
+
const articleIds = ['article1', 'article2'];
|
|
955
|
+
const categories = await dataLoader.categoriesLoader.loadMany(
|
|
956
|
+
articleIds.map(id => ({ articleId: id, language: 'en-US' }))
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
expect(categories).toHaveLength(2);
|
|
960
|
+
expect(categories[0]).toBeInstanceOf(Array);
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
## Best Practices
|
|
966
|
+
|
|
967
|
+
### Schema Design
|
|
968
|
+
- Use consistent naming conventions for all GraphQL types
|
|
969
|
+
- Implement proper pagination for all collection queries
|
|
970
|
+
- Design efficient DataLoader patterns to prevent N+1 queries
|
|
971
|
+
- Use nullable fields appropriately to handle missing data
|
|
972
|
+
|
|
973
|
+
### Performance
|
|
974
|
+
- Implement query complexity analysis to prevent expensive queries
|
|
975
|
+
- Use DataLoader for all relationship queries
|
|
976
|
+
- Cache frequently accessed data with appropriate TTL
|
|
977
|
+
- Optimize database queries with proper indexing
|
|
978
|
+
|
|
979
|
+
### Security
|
|
980
|
+
- Implement proper authentication and authorization
|
|
981
|
+
- Validate all input parameters and payloads
|
|
982
|
+
- Use rate limiting to prevent abuse
|
|
983
|
+
- Sanitize user-generated content
|
|
984
|
+
|
|
985
|
+
### Development
|
|
986
|
+
- Write comprehensive tests for all resolvers
|
|
987
|
+
- Use TypeScript for type safety across the GraphQL schema
|
|
988
|
+
- Implement proper error handling and logging
|
|
989
|
+
- Follow GraphQL best practices for schema evolution
|
|
990
|
+
|
|
991
|
+
## Environment Configuration
|
|
992
|
+
|
|
993
|
+
```bash
|
|
994
|
+
# .env
|
|
995
|
+
DATABASE_HOST=localhost
|
|
996
|
+
DATABASE_PORT=5432
|
|
997
|
+
DATABASE_NAME=cms_db
|
|
998
|
+
DATABASE_USER=cms_user
|
|
999
|
+
DATABASE_PASSWORD=secure_password
|
|
1000
|
+
|
|
1001
|
+
CMS_MULTI_LANGUAGE=true
|
|
1002
|
+
CMS_DRAFT_MODE=true
|
|
1003
|
+
CMS_FULL_TEXT_SEARCH=true
|
|
1004
|
+
CMS_AUTO_RELEASE=false
|
|
1005
|
+
|
|
1006
|
+
REDIS_HOST=localhost
|
|
1007
|
+
REDIS_PORT=6379
|
|
1008
|
+
REDIS_PASSWORD=redis_password
|
|
1009
|
+
|
|
1010
|
+
GRAPHQL_PLAYGROUND=true
|
|
1011
|
+
GRAPHQL_INTROSPECTION=true
|
|
1012
|
+
```
|
|
1013
|
+
|
|
1014
|
+
## License
|
|
1015
|
+
|
|
1016
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rytass/cms-base-nestjs-graphql-module",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Rytass Content Management System NestJS Base GraphQL Module",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rytass",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"typeorm": "*"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@rytass/cms-base-nestjs-module": "^0.2.
|
|
30
|
-
"@rytass/member-base-nestjs-module": "^0.2.
|
|
29
|
+
"@rytass/cms-base-nestjs-module": "^0.2.4",
|
|
30
|
+
"@rytass/member-base-nestjs-module": "^0.2.8",
|
|
31
31
|
"dataloader": "^2.2.2",
|
|
32
32
|
"lru-cache": "^11.0.2"
|
|
33
33
|
},
|