JSPM

  • Created
  • Published
  • Downloads 193
  • Score
    100M100P100Q99275F
  • License MIT

Rytass Content Management System NestJS Base GraphQL Module

Package Exports

  • @rytass/cms-base-nestjs-graphql-module
  • @rytass/cms-base-nestjs-graphql-module/index.cjs.js
  • @rytass/cms-base-nestjs-graphql-module/index.js

This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (@rytass/cms-base-nestjs-graphql-module) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.

Readme

Rytass Utils - CMS Base NestJS GraphQL Module

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.

Features

Core GraphQL API

  • Complete GraphQL schema for articles and categories
  • Public and backstage (admin) API endpoints
  • Multi-language content delivery
  • Real-time content queries with filters
  • Paginated collections with metadata

Advanced Content Management

  • Article approval workflow resolvers
  • Version control and draft management
  • Category hierarchical relationships
  • Custom field support in GraphQL
  • Full-text search integration

Performance Optimization

  • DataLoader pattern for N+1 query prevention
  • Member data caching with LRU cache
  • Article relationship optimization
  • Efficient batch loading for categories
  • Query complexity analysis

Integration Features

  • Member system integration (@rytass/member-base-nestjs-module)
  • Permission-based access control
  • Quadrats rich content editor support
  • Multi-language decorator system
  • TypeORM entity relationships

Installation

npm install @rytass/cms-base-nestjs-graphql-module @rytass/cms-base-nestjs-module
# Peer dependencies
npm install @nestjs/common @nestjs/typeorm @nestjs/graphql typeorm
# or
yarn add @rytass/cms-base-nestjs-graphql-module @rytass/cms-base-nestjs-module

Basic Setup

Module Configuration

// app.module.ts
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CMSBaseGraphQLModule } from '@rytass/cms-base-nestjs-graphql-module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'username',
      password: 'password',
      database: 'cms_database',
      autoLoadEntities: true,
      synchronize: true, // Development only
    }),
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: 'schema.gql',
      sortSchema: true,
      playground: true,
      introspection: true,
    }),
    CMSBaseGraphQLModule.forRoot({
      multipleLanguageMode: true,
      draftMode: true,
      signatureLevels: [
        { id: 1, name: 'Editor', level: 1 },
        { id: 2, name: 'Senior Editor', level: 2 },
        { id: 3, name: 'Chief Editor', level: 3 }
      ],
      fullTextSearchMode: true,
      autoReleaseAfterApproved: false
    })
  ],
})
export class AppModule {}

Async Configuration

// app.module.ts
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CMSBaseGraphQLModule } from '@rytass/cms-base-nestjs-graphql-module';

@Module({
  imports: [
    ConfigModule.forRoot(),
    CMSBaseGraphQLModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        multipleLanguageMode: configService.get('CMS_MULTI_LANGUAGE') === 'true',
        draftMode: configService.get('CMS_DRAFT_MODE') === 'true',
        signatureLevels: JSON.parse(configService.get('CMS_SIGNATURE_LEVELS') || '[]'),
        fullTextSearchMode: configService.get('CMS_FULL_TEXT_SEARCH') === 'true',
        autoReleaseAfterApproved: configService.get('CMS_AUTO_RELEASE') === 'true'
      })
    })
  ],
})
export class AppModule {}

GraphQL API Usage

Public Queries

Query Single Article

query GetArticle($id: ID!, $language: String) {
  article(id: $id) {
    articleId
    title
    description
    content
    publishedAt
    releasedBy {
      id
      username
      email
    }
    categories {
      id
      name
      slug
      parentCategory {
        id
        name
      }
    }
  }
}
// Apollo Client usage
import { gql, useQuery } from '@apollo/client';

const GET_ARTICLE = gql`
  query GetArticle($id: ID!) {
    article(id: $id) {
      articleId
      title
      description
      content
      publishedAt
      categories {
        id
        name
        slug
      }
    }
  }
`;

function ArticlePage({ articleId }: { articleId: string }) {
  const { loading, error, data } = useQuery(GET_ARTICLE, {
    variables: { id: articleId }
  });

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <article>
      <h1>{data.article.title}</h1>
      <p>{data.article.description}</p>
      {/* Render Quadrats content */}
    </article>
  );
}

Query Article Collection

query GetArticles(
  $page: Int
  $limit: Int
  $categoryId: ID
  $search: String
  $language: String
) {
  articles(
    page: $page
    limit: $limit
    categoryId: $categoryId
    search: $search
  ) {
    items {
      articleId
      title
      description
      publishedAt
      categories {
        id
        name
        slug
      }
    }
    meta {
      totalCount
      pageCount
      currentPage
      hasNextPage
      hasPreviousPage
    }
  }
}
// React component with pagination
import { gql, useQuery } from '@apollo/client';

const GET_ARTICLES = gql`
  query GetArticles($page: Int, $limit: Int, $categoryId: ID) {
    articles(page: $page, limit: $limit, categoryId: $categoryId) {
      items {
        articleId
        title
        description
        publishedAt
      }
      meta {
        totalCount
        currentPage
        hasNextPage
      }
    }
  }
`;

function ArticleList() {
  const [page, setPage] = useState(1);
  const { loading, error, data } = useQuery(GET_ARTICLES, {
    variables: { page, limit: 10 }
  });

  return (
    <div>
      {data?.articles.items.map((article) => (
        <ArticleCard key={article.articleId} article={article} />
      ))}
      
      <Pagination 
        currentPage={data?.articles.meta.currentPage}
        hasNextPage={data?.articles.meta.hasNextPage}
        onPageChange={setPage}
      />
    </div>
  );
}

Backstage (Admin) Queries

Query Articles with Management Data

query GetBackstageArticles(
  $page: Int
  $limit: Int
  $stage: String
  $authorId: ID
) {
  backstageArticles(
    page: $page
    limit: $limit
    stage: $stage
    authorId: $authorId
  ) {
    items {
      articleId
      title
      stage
      createdAt
      updatedAt
      author {
        id
        username
      }
      signature {
        id
        approved
        approvedAt
        level
        reviewer {
          id
          username
        }
      }
    }
    meta {
      totalCount
      pageCount
    }
  }
}

Query Article Versions

query GetArticleVersions($articleId: ID!) {
  backstageArticle(id: $articleId) {
    articleId
    title
    stage
    versions {
      id
      versionNumber
      createdAt
      author {
        username
      }
      changes {
        field
        oldValue
        newValue
      }
    }
  }
}

Mutations

Create Article

mutation CreateArticle($input: CreateArticleInput!) {
  createArticle(input: $input) {
    articleId
    title
    stage
    createdAt
  }
}
// Apollo Client mutation
import { gql, useMutation } from '@apollo/client';

const CREATE_ARTICLE = gql`
  mutation CreateArticle($input: CreateArticleInput!) {
    createArticle(input: $input) {
      articleId
      title
      stage
    }
  }
`;

function CreateArticleForm() {
  const [createArticle, { loading, error }] = useMutation(CREATE_ARTICLE);

  const handleSubmit = async (formData: any) => {
    try {
      const { data } = await createArticle({
        variables: {
          input: {
            title: {
              'en-US': formData.titleEn,
              'zh-TW': formData.titleZh
            },
            content: formData.content,
            categoryIds: formData.categories,
            customFields: formData.customFields
          }
        }
      });
      
      console.log('Article created:', data.createArticle);
    } catch (err) {
      console.error('Error creating article:', err);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Form fields */}
    </form>
  );
}

Update Article

mutation UpdateArticle($id: ID!, $input: UpdateArticleInput!) {
  updateArticle(id: $id, input: $input) {
    articleId
    title
    stage
    updatedAt
  }
}

Approve Article

mutation ApproveArticle($articleId: ID!, $level: Int!, $comments: String) {
  approveArticle(
    articleId: $articleId
    level: $level
    comments: $comments
  ) {
    id
    approved
    approvedAt
    level
    comments
  }
}

Multi-Language Support

Language Context

// Custom decorator for language detection
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

export const Language = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const ctx = GqlExecutionContext.create(context);
    const request = ctx.getContext().req;
    
    // Extract language from headers, query params, or JWT token
    return request.headers['accept-language'] || 
           request.query.language || 
           'en-US';
  }
);

Multi-Language Queries

query GetMultiLanguageArticle($id: ID!) {
  article(id: $id) {
    articleId
    title # Returns title in requested language
    description
    content
    multiLanguageTitle {
      language
      value
    }
    multiLanguageContent {
      language
      value
    }
  }
}
// Frontend language switching
function ArticleWithLanguage({ articleId }: { articleId: string }) {
  const [language, setLanguage] = useState('en-US');
  
  const { data } = useQuery(GET_ARTICLE, {
    variables: { id: articleId },
    context: {
      headers: {
        'Accept-Language': language
      }
    }
  });

  return (
    <div>
      <LanguageSelector onChange={setLanguage} />
      <article>
        <h1>{data?.article.title}</h1>
        <div>{data?.article.content}</div>
      </article>
    </div>
  );
}

Advanced Features

Custom Resolvers

// custom-article.resolver.ts
import { Resolver, Query, ResolveField, Parent } from '@nestjs/graphql';
import { ArticleDto } from '@rytass/cms-base-nestjs-graphql-module';

@Resolver(() => ArticleDto)
export class CustomArticleResolver {
  
  @ResolveField(() => String, { nullable: true })
  async seoTitle(@Parent() article: ArticleDto): Promise<string | null> {
    // Custom SEO title logic
    return article.title + ' | Your Site Name';
  }

  @ResolveField(() => [String])
  async tags(@Parent() article: ArticleDto): Promise<string[]> {
    // Extract tags from content or custom fields
    return article.customFields?.tags || [];
  }

  @ResolveField(() => Int)
  async readingTime(@Parent() article: ArticleDto): Promise<number> {
    // Calculate reading time based on content
    const wordCount = this.countWords(article.content);
    return Math.ceil(wordCount / 200); // Assuming 200 WPM
  }

  private countWords(content: any[]): number {
    // Implement word counting logic for Quadrats content
    return content.reduce((count, block) => {
      if (block.type === 'text') {
        return count + (block.text?.split(' ').length || 0);
      }
      return count;
    }, 0);
  }
}

DataLoader Optimization

// custom-dataloader.service.ts
import { Injectable } from '@nestjs/common';
import DataLoader from 'dataloader';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';

@Injectable()
export class CustomDataLoaderService {
  
  // Article view count loader
  public readonly articleViewsLoader = new DataLoader<string, number>(
    async (articleIds: string[]) => {
      const viewCounts = await this.getArticleViewCounts(articleIds);
      return articleIds.map(id => viewCounts[id] || 0);
    }
  );

  // Related articles loader
  public readonly relatedArticlesLoader = new DataLoader<string, ArticleDto[]>(
    async (articleIds: string[]) => {
      const relatedArticles = await this.getRelatedArticles(articleIds);
      return articleIds.map(id => relatedArticles[id] || []);
    }
  );

  private async getArticleViewCounts(articleIds: string[]): Promise<Record<string, number>> {
    // Implement batch view count fetching
    return {};
  }

  private async getRelatedArticles(articleIds: string[]): Promise<Record<string, ArticleDto[]>> {
    // Implement batch related articles fetching
    return {};
  }
}

Search Integration

query SearchArticles(
  $query: String!
  $filters: SearchFiltersInput
  $page: Int
  $limit: Int
) {
  searchArticles(
    query: $query
    filters: $filters
    page: $page
    limit: $limit
  ) {
    items {
      articleId
      title
      description
      searchScore
      highlights {
        field
        snippets
      }
    }
    meta {
      totalCount
      searchTime
      suggestions
    }
    facets {
      categories {
        id
        name
        count
      }
      authors {
        id
        name
        count
      }
    }
  }
}

Permission-Based Access

Role-Based Queries

// permissions.resolver.ts
import { Resolver, Query, UseGuards } from '@nestjs/graphql';
import { AllowActions, RequireActions } from '@rytass/member-base-nestjs-module';
import { BaseAction } from './constants/enum/base-action.enum';
import { BaseResource } from './constants/enum/base-resource.enum';

@Resolver()
export class PermissionResolver {
  
  @Query(() => [BackstageArticleDto])
  @RequireActions([BaseAction.READ], BaseResource.ARTICLE)
  async getAllArticles(): Promise<BackstageArticleDto[]> {
    // Only accessible to users with READ permission on ARTICLE resource
    return this.articleService.findAll();
  }

  @Query(() => BackstageArticleDto)
  @RequireActions([BaseAction.UPDATE], BaseResource.ARTICLE)
  async getEditableArticle(@Args('id') id: string): Promise<BackstageArticleDto> {
    // Only accessible to users with UPDATE permission
    return this.articleService.findEditableById(id);
  }
}

Context-Based Security

// security.resolver.ts
import { Resolver, Query, Context } from '@nestjs/graphql';

@Resolver()
export class SecurityResolver {
  
  @Query(() => [ArticleDto])
  async getUserArticles(@Context() context: any): Promise<ArticleDto[]> {
    const userId = context.req.user?.id;
    
    if (!userId) {
      throw new UnauthorizedException('User not authenticated');
    }

    return this.articleService.findByAuthor(userId);
  }
}

Integration Examples

Frontend Integration (React + Apollo)

// ArticleManagement.tsx
import React from 'react';
import { useQuery, useMutation } from '@apollo/client';
import { GET_BACKSTAGE_ARTICLES, APPROVE_ARTICLE } from './queries';

export function ArticleManagement() {
  const { data, loading, refetch } = useQuery(GET_BACKSTAGE_ARTICLES, {
    variables: { page: 1, limit: 20, stage: 'PENDING' }
  });

  const [approveArticle] = useMutation(APPROVE_ARTICLE, {
    onCompleted: () => refetch()
  });

  const handleApprove = async (articleId: string) => {
    await approveArticle({
      variables: {
        articleId,
        level: 2,
        comments: 'Approved for publication'
      }
    });
  };

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      <h2>Pending Articles</h2>
      {data?.backstageArticles.items.map((article: any) => (
        <ArticleCard
          key={article.articleId}
          article={article}
          onApprove={() => handleApprove(article.articleId)}
        />
      ))}
    </div>
  );
}

Mobile App Integration (React Native + Apollo)

// ArticleList.tsx
import React from 'react';
import { FlatList, Text, View } from 'react-native';
import { useQuery } from '@apollo/client';
import { GET_ARTICLES } from './queries';

export function ArticleList() {
  const { data, loading, fetchMore } = useQuery(GET_ARTICLES, {
    variables: { page: 1, limit: 10 }
  });

  const loadMore = () => {
    if (data?.articles.meta.hasNextPage) {
      fetchMore({
        variables: {
          page: data.articles.meta.currentPage + 1
        },
        updateQuery: (prev, { fetchMoreResult }) => {
          if (!fetchMoreResult) return prev;
          
          return {
            articles: {
              ...fetchMoreResult.articles,
              items: [
                ...prev.articles.items,
                ...fetchMoreResult.articles.items
              ]
            }
          };
        }
      });
    }
  };

  return (
    <FlatList
      data={data?.articles.items}
      renderItem={({ item }) => (
        <View>
          <Text>{item.title}</Text>
          <Text>{item.description}</Text>
        </View>
      )}
      onEndReached={loadMore}
      onEndReachedThreshold={0.5}
    />
  );
}

Next.js SSR Integration

// pages/articles/[id].tsx
import { GetServerSideProps } from 'next';
import { ApolloClient, InMemoryCache, gql } from '@apollo/client';

export default function ArticlePage({ article }: { article: any }) {
  return (
    <div>
      <h1>{article.title}</h1>
      <div>{article.description}</div>
      {/* Render article content */}
    </div>
  );
}

export const getServerSideProps: GetServerSideProps = async ({ params, req }) => {
  const client = new ApolloClient({
    uri: process.env.GRAPHQL_ENDPOINT,
    cache: new InMemoryCache(),
    headers: {
      'Accept-Language': req.headers['accept-language'] || 'en-US'
    }
  });

  const { data } = await client.query({
    query: gql`
      query GetArticle($id: ID!) {
        article(id: $id) {
          articleId
          title
          description
          content
        }
      }
    `,
    variables: { id: params?.id }
  });

  return {
    props: {
      article: data.article
    }
  };
};

Performance Optimization

Query Complexity Analysis

// complexity.config.ts
import { createComplexityLimitRule } from 'graphql-query-complexity';

export const complexityConfig = {
  maximumComplexity: 1000,
  validators: [createComplexityLimitRule(1000)],
  createError: (max: number, actual: number) => {
    return new Error(`Query is too complex: ${actual}. Maximum allowed complexity: ${max}`);
  }
};

Caching Strategy

// cache.config.ts
import { CacheModule } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-redis-store';

@Module({
  imports: [
    CacheModule.register({
      store: redisStore,
      host: 'localhost',
      port: 6379,
      ttl: 600, // 10 minutes
    })
  ]
})
export class CacheConfig {}

// Cached resolver
@Resolver()
export class CachedArticleResolver {
  
  @Query(() => [ArticleDto])
  @UseInterceptors(CacheInterceptor)
  @CacheTTL(300) // 5 minutes
  async popularArticles(): Promise<ArticleDto[]> {
    return this.articleService.findPopular();
  }
}

Testing

GraphQL Testing

// article.resolver.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { GraphQLModule } from '@nestjs/graphql';

describe('Article Resolver (e2e)', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [
        GraphQLModule.forRoot({
          autoSchemaFile: true,
        }),
        CMSBaseGraphQLModule.forRoot({
          multipleLanguageMode: false,
          draftMode: true,
        })
      ],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('should get article by id', () => {
    const query = `
      query {
        article(id: "test-id") {
          articleId
          title
          description
        }
      }
    `;

    return request(app.getHttpServer())
      .post('/graphql')
      .send({ query })
      .expect(200)
      .expect((res) => {
        expect(res.body.data.article).toBeDefined();
        expect(res.body.data.article.articleId).toBe('test-id');
      });
  });
});

DataLoader Testing

// dataloader.spec.ts
import { Test } from '@nestjs/testing';
import { ArticleDataLoader } from '../data-loaders/article.dataloader';

describe('ArticleDataLoader', () => {
  let dataLoader: ArticleDataLoader;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [ArticleDataLoader]
    }).compile();

    dataLoader = module.get<ArticleDataLoader>(ArticleDataLoader);
  });

  it('should batch load categories', async () => {
    const articleIds = ['article1', 'article2'];
    const categories = await dataLoader.categoriesLoader.loadMany(
      articleIds.map(id => ({ articleId: id, language: 'en-US' }))
    );

    expect(categories).toHaveLength(2);
    expect(categories[0]).toBeInstanceOf(Array);
  });
});

Best Practices

Schema Design

  • Use consistent naming conventions for all GraphQL types
  • Implement proper pagination for all collection queries
  • Design efficient DataLoader patterns to prevent N+1 queries
  • Use nullable fields appropriately to handle missing data

Performance

  • Implement query complexity analysis to prevent expensive queries
  • Use DataLoader for all relationship queries
  • Cache frequently accessed data with appropriate TTL
  • Optimize database queries with proper indexing

Security

  • Implement proper authentication and authorization
  • Validate all input parameters and payloads
  • Use rate limiting to prevent abuse
  • Sanitize user-generated content

Development

  • Write comprehensive tests for all resolvers
  • Use TypeScript for type safety across the GraphQL schema
  • Implement proper error handling and logging
  • Follow GraphQL best practices for schema evolution

Environment Configuration

# .env
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=cms_db
DATABASE_USER=cms_user
DATABASE_PASSWORD=secure_password

CMS_MULTI_LANGUAGE=true
CMS_DRAFT_MODE=true
CMS_FULL_TEXT_SEARCH=true
CMS_AUTO_RELEASE=false

REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=redis_password

GRAPHQL_PLAYGROUND=true
GRAPHQL_INTROSPECTION=true

License

MIT