diff --git a/.eslintrc.json b/.eslintrc.json index 2ec8b141755..ee5ab3d6764 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -26,7 +26,11 @@ "import/newline-after-import": "error", "import/no-named-default": "error", "import/no-anonymous-default-export": "error", - "import/dynamic-import-chunkname": "error" + "import/dynamic-import-chunkname": "error", + "@typescript-eslint/no-empty-function": [ + "error", + { "allow": ["decoratedFunctions"] } + ] }, "overrides": [ { diff --git a/.gitignore b/.gitignore index 49b72d104e9..58ddb994d38 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ testem.log Thumbs.db #local .env files +.env .env*local # kube secrets diff --git a/apps/api-gateway/project.json b/apps/api-gateway/project.json index 30704091044..f0e0b4a1e15 100644 --- a/apps/api-gateway/project.json +++ b/apps/api-gateway/project.json @@ -44,6 +44,9 @@ }, { "command": "nx serve api-gateway" + }, + { + "command": "nx serve api-languages" } ], "parallel": true diff --git a/apps/api-gateway/schema.graphql b/apps/api-gateway/schema.graphql index cbc0f5f958a..c56111d07b7 100644 --- a/apps/api-gateway/schema.graphql +++ b/apps/api-gateway/schema.graphql @@ -289,6 +289,7 @@ scalar join__FieldSet enum join__Graph { JOURNEYS @join__graph(name: "journeys" url: "http://127.0.0.1:4001/graphql") + LANGUAGES @join__graph(name: "languages" url: "http://127.0.0.1:4003/graphql") USERS @join__graph(name: "users" url: "http://127.0.0.1:4002/graphql") } @@ -349,6 +350,16 @@ input JourneyUpdateInput { title: String } +type Language + @join__owner(graph: LANGUAGES) + @join__type(graph: LANGUAGES, key: "id") +{ + bcp47: String @join__field(graph: LANGUAGES) + id: ID! @join__field(graph: LANGUAGES) + iso3: String @join__field(graph: LANGUAGES) + name(languageId: ID, primary: Boolean): [Translation]! @join__field(graph: LANGUAGES) +} + type LinkAction implements Action { gtmEventName: String parentBlockId: ID! @@ -444,6 +455,8 @@ type Query { adminJourneys: [Journey!]! @join__field(graph: JOURNEYS) journey(id: ID!, idType: IdType): Journey @join__field(graph: JOURNEYS) journeys: [Journey!]! @join__field(graph: JOURNEYS) + language(id: ID!): Language @join__field(graph: LANGUAGES) + languages(limit: Int, page: Int): [Language!]! @join__field(graph: LANGUAGES) me: User @join__field(graph: USERS) } @@ -596,6 +609,12 @@ enum ThemeName { base } +type Translation { + language: Language! + primary: Boolean! + value: String! +} + enum TypographyAlign { center left diff --git a/apps/api-gateway/supergraph.yml b/apps/api-gateway/supergraph.yml index 46649d623c1..b729d0e0efd 100644 --- a/apps/api-gateway/supergraph.yml +++ b/apps/api-gateway/supergraph.yml @@ -7,3 +7,7 @@ subgraphs: routing_url: http://127.0.0.1:4002/graphql schema: file: ../api-users/schema.graphql + languages: + routing_url: http://127.0.0.1:4003/graphql + schema: + file: ../api-languages/schema.graphql diff --git a/apps/api-languages/.env.example b/apps/api-languages/.env.example new file mode 100644 index 00000000000..e0df6388eda --- /dev/null +++ b/apps/api-languages/.env.example @@ -0,0 +1,2 @@ +DATABASE_URL="arangodb://arangodb:8529" +ARCLIGHT_API_KEY= \ No newline at end of file diff --git a/apps/api-languages/.eslintrc.json b/apps/api-languages/.eslintrc.json new file mode 100644 index 00000000000..a8ae95b9ee0 --- /dev/null +++ b/apps/api-languages/.eslintrc.json @@ -0,0 +1,21 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "parserOptions": { + "project": ["apps/api-languages/tsconfig.*?.json"] + }, + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/api-languages/Dockerfile b/apps/api-languages/Dockerfile new file mode 100644 index 00000000000..ed4bbb63c49 --- /dev/null +++ b/apps/api-languages/Dockerfile @@ -0,0 +1,8 @@ +FROM node:14-alpine +WORKDIR /app +COPY ./dist/apps/api-languages . +EXPOSE 4003 +# dependencies that nestjs needs +RUN npm install --production --silent +RUN npm install tslib apollo-server-express @nestjs/mapped-types +CMD node ./main.js \ No newline at end of file diff --git a/apps/api-languages/db/db.ts b/apps/api-languages/db/db.ts new file mode 100644 index 00000000000..4a12769acc5 --- /dev/null +++ b/apps/api-languages/db/db.ts @@ -0,0 +1,13 @@ +import { Database } from 'arangojs' + +export function ArangoDB(): Database { + let db: Database + if (process.env.DATABASE_DB != null) + db = new Database({ + url: process.env.DATABASE_URL, + databaseName: process.env.DATABASE_DB + }) + else db = new Database({ url: process.env.DATABASE_URL }) + db.useBasicAuth(process.env.DATABASE_USER, process.env.DATABASE_PASS) + return db +} diff --git a/apps/api-languages/db/seed.ts b/apps/api-languages/db/seed.ts new file mode 100644 index 00000000000..b5df04a57cc --- /dev/null +++ b/apps/api-languages/db/seed.ts @@ -0,0 +1,132 @@ +import { aql } from 'arangojs' +import { isEmpty } from 'lodash' +import fetch from 'node-fetch' +import { ArangoDB } from './db' + +interface Language { + _key: string + name: Array<{ value: string; languageId: string; primary: boolean }> + bcp47?: string + iso3?: string +} + +interface MediaLanguage { + languageId: number + bcp47?: string + iso3?: string + nameNative: string + metadataLanguageTag: string + name: string +} + +const db = ArangoDB() + +async function getLanguage(languageId: string): Promise { + const rst = await db.query(aql` + FOR item IN ${db.collection('languages')} + FILTER item._key == ${languageId} + LIMIT 1 + RETURN item`) + return await rst.next() +} + +async function getLanguageByBcp47( + bcp47: string +): Promise { + const rst = await db.query(aql` + FOR item IN ${db.collection('languages')} + FILTER item.bcp47 == ${bcp47} + LIMIT 1 + RETURN item`) + return await rst.next() +} + +async function getMediaLanguages(): Promise { + const response: { + _embedded: { mediaLanguages: MediaLanguage[] } + } = await ( + await fetch( + `https://api.arclight.org/v2/media-languages?limit=5000&apiKey=${ + process.env.ARCLIGHT_API_KEY ?? '' + }` + ) + ).json() + return response._embedded.mediaLanguages +} + +async function digestMediaLanguage( + mediaLanguage: MediaLanguage +): Promise { + const { languageId, bcp47, iso3, nameNative } = mediaLanguage + const body: Omit = { + bcp47: isEmpty(bcp47) ? undefined : bcp47, + iso3: isEmpty(iso3) ? undefined : iso3, + name: [ + { + value: nameNative, + languageId: languageId.toString(), + primary: true + } + ] + } + const language = await getLanguage(languageId.toString()) + + if (language != null) { + await db.collection('languages').update(languageId.toString(), body) + } else { + await db + .collection('languages') + .save({ _key: languageId.toString(), ...body }) + } +} + +async function digestMediaLanguageMetadata( + mediaLanguage: MediaLanguage +): Promise { + const { languageId, metadataLanguageTag, name } = mediaLanguage + const language = await getLanguage(languageId.toString()) + if (language == null) return + + const metadataLanguage = await getLanguageByBcp47(metadataLanguageTag) + if (metadataLanguage == null) return + + if ( + language.name.find( + ({ languageId }) => languageId === metadataLanguage._key + ) != null + ) + return + + const body: Omit = { + name: [ + ...language.name, + { + value: name, + languageId: metadataLanguage._key, + primary: false + } + ] + } + await db.collection('languages').update(languageId.toString(), body) +} + +async function main(): Promise { + try { + await db.createCollection('languages', { keyOptions: { type: 'uuid' } }) + } catch {} + const mediaLanguages = await getMediaLanguages() + + for (const mediaLanguage of mediaLanguages) { + console.log('language:', mediaLanguage.languageId) + await digestMediaLanguage(mediaLanguage) + } + + for (const mediaLanguage of mediaLanguages) { + await digestMediaLanguageMetadata(mediaLanguage) + } +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/apps/api-languages/index.ts b/apps/api-languages/index.ts new file mode 100644 index 00000000000..da70d960f6c --- /dev/null +++ b/apps/api-languages/index.ts @@ -0,0 +1 @@ +export * from './src/app/__generated__/graphql' diff --git a/apps/api-languages/jest.config.js b/apps/api-languages/jest.config.js new file mode 100644 index 00000000000..c6905415b9d --- /dev/null +++ b/apps/api-languages/jest.config.js @@ -0,0 +1,15 @@ +module.exports = { + displayName: 'api-languages', + preset: '../../jest.preset.js', + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json' + } + }, + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': 'ts-jest' + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/apps/api-languages' +} diff --git a/apps/api-languages/project.json b/apps/api-languages/project.json new file mode 100644 index 00000000000..921e3d2c538 --- /dev/null +++ b/apps/api-languages/project.json @@ -0,0 +1,90 @@ +{ + "root": "apps/api-languages", + "sourceRoot": "apps/api-languages/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nrwl/node:build", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/api-languages", + "main": "apps/api-languages/src/main.ts", + "tsConfig": "apps/api-languages/tsconfig.app.json", + "assets": [ + "apps/api-languages/src/assets", + { + "glob": "**/*.graphql", + "input": "apps/api-languages/src/app/", + "output": "./assets" + } + ], + "generatePackageJson": true + }, + "configurations": { + "production": { + "optimization": true, + "extractLicenses": true, + "inspect": false, + "fileReplacements": [ + { + "replace": "apps/api-languages/src/environments/environment.ts", + "with": "apps/api-languages/src/environments/environment.prod.ts" + } + ] + } + } + }, + "seed": { + "executor": "@nrwl/workspace:run-commands", + "options": { + "command": "npx ts-node apps/api-languages/db/seed.ts" + } + }, + "serve-nowatch": { + "executor": "@nrwl/node:execute", + "options": { + "buildTarget": "api-languages:build" + } + }, + "serve": { + "executor": "@nrwl/workspace:run-commands", + "options": { + "commands": [ + { + "command": "nx serve-nowatch api-languages" + }, + { + "command": "npx ts-node apps/api-languages/src/generate-typings.ts" + } + ], + "parallel": true + } + }, + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/api-languages/**/*.ts"] + } + }, + "test": { + "executor": "@nrwl/jest:jest", + "outputs": ["coverage/apps/api-languages"], + "options": { + "jestConfig": "apps/api-languages/jest.config.js", + "passWithNoTests": true + } + }, + "generate-graphql": { + "executor": "@nrwl/workspace:run-commands", + "options": { + "commands": [ + { + "command": "rover subgraph introspect http://localhost:4003/graphql > apps/api-languages/schema.graphql" + } + ] + } + } + }, + "tags": [] +} diff --git a/apps/api-languages/schema.graphql b/apps/api-languages/schema.graphql new file mode 100644 index 00000000000..f61eaa8cb5d --- /dev/null +++ b/apps/api-languages/schema.graphql @@ -0,0 +1,17 @@ +type Language @key(fields: "id") { + id: ID! + bcp47: String + iso3: String + name(languageId: ID, primary: Boolean): [Translation]! +} + +type Translation { + value: String! + language: Language! + primary: Boolean! +} + +extend type Query { + languages(page: Int, limit: Int): [Language!]! + language(id: ID!): Language +} diff --git a/apps/api-languages/src/app/.gitkeep b/apps/api-languages/src/app/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/api-languages/src/app/__generated__/graphql.ts b/apps/api-languages/src/app/__generated__/graphql.ts new file mode 100644 index 00000000000..f7392a1fc28 --- /dev/null +++ b/apps/api-languages/src/app/__generated__/graphql.ts @@ -0,0 +1,31 @@ + +/* + * ------------------------------------------------------- + * THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) + * ------------------------------------------------------- + */ + +/* tslint:disable */ +/* eslint-disable */ +export class Language { + __typename?: 'Language'; + id: string; + bcp47?: Nullable; + iso3?: Nullable; + name: Nullable[]; +} + +export class Translation { + __typename?: 'Translation'; + value: string; + language: Language; + primary: boolean; +} + +export abstract class IQuery { + abstract languages(page?: Nullable, limit?: Nullable): Language[] | Promise; + + abstract language(id: string): Nullable | Promise>; +} + +type Nullable = T | null; diff --git a/apps/api-languages/src/app/app.module.ts b/apps/api-languages/src/app/app.module.ts new file mode 100644 index 00000000000..89d01c445ba --- /dev/null +++ b/apps/api-languages/src/app/app.module.ts @@ -0,0 +1,21 @@ +import { join } from 'path' +import { Module } from '@nestjs/common' +import { GraphQLFederationModule } from '@nestjs/graphql' +import { LanguageModule } from './modules/language/language.module' +import { TranslationModule } from './modules/translation/translation.module' + +@Module({ + imports: [ + LanguageModule, + TranslationModule, + GraphQLFederationModule.forRoot({ + typePaths: [ + join(process.cwd(), 'apps/api-languages/src/app/**/*.graphql'), + join(process.cwd(), 'assets/**/*.graphql') + ], + cors: true, + context: ({ req }) => ({ headers: req.headers }) + }) + ] +}) +export class AppModule {} diff --git a/apps/api-languages/src/app/modules/language/language.graphql b/apps/api-languages/src/app/modules/language/language.graphql new file mode 100644 index 00000000000..17e32e68ca9 --- /dev/null +++ b/apps/api-languages/src/app/modules/language/language.graphql @@ -0,0 +1,11 @@ +type Language @key(fields: "id") { + id: ID! + bcp47: String + iso3: String + name(languageId: ID, primary: Boolean): [Translation]! +} + +extend type Query { + languages(page: Int, limit: Int): [Language!]! + language(id: ID!): Language +} diff --git a/apps/api-languages/src/app/modules/language/language.module.ts b/apps/api-languages/src/app/modules/language/language.module.ts new file mode 100644 index 00000000000..9a1887e99b6 --- /dev/null +++ b/apps/api-languages/src/app/modules/language/language.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '@core/nest/database' +import { LanguageResolver } from './language.resolver' +import { LanguageService } from './language.service' + +@Module({ + imports: [DatabaseModule], + providers: [LanguageResolver, LanguageService], + exports: [LanguageService] +}) +export class LanguageModule {} diff --git a/apps/api-languages/src/app/modules/language/language.resolver.spec.ts b/apps/api-languages/src/app/modules/language/language.resolver.spec.ts new file mode 100644 index 00000000000..11fde839f03 --- /dev/null +++ b/apps/api-languages/src/app/modules/language/language.resolver.spec.ts @@ -0,0 +1,68 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { LanguageResolver } from './language.resolver' +import { LanguageService } from './language.service' + +describe('LangaugeResolver', () => { + let resolver: LanguageResolver, service: LanguageService + + const language = { + id: '20615', + bcp47: 'zh', + name: [ + { + value: '普通話', + primary: true, + languageId: '20615' + }, + { + value: 'Chinese, Mandarin', + primary: false, + languageId: '529' + } + ] + } + + beforeEach(async () => { + const languageService = { + provide: LanguageService, + useFactory: () => ({ + get: jest.fn(() => language), + getAll: jest.fn(() => [language, language]) + }) + } + const module: TestingModule = await Test.createTestingModule({ + providers: [LanguageResolver, languageService] + }).compile() + resolver = module.get(LanguageResolver) + service = await module.resolve(LanguageService) + }) + + describe('languages', () => { + it('returns Languages', async () => { + expect(await resolver.languages(1, 2)).toEqual([language, language]) + expect(service.getAll).toHaveBeenCalledWith(1, 2) + }) + }) + + describe('language', () => { + it('should return language', async () => { + expect(await resolver.language(language.id)).toEqual(language) + }) + }) + + describe('name', () => { + it('should return translations', () => { + expect(resolver.name(language)).toEqual(language.name) + }) + + it('should return translations filtered by languageId', () => { + expect(resolver.name(language, '529')).toEqual([language.name[1]]) + }) + + it('should return translations filtered by primary', () => { + expect(resolver.name(language, undefined, true)).toEqual([ + language.name[0] + ]) + }) + }) +}) diff --git a/apps/api-languages/src/app/modules/language/language.resolver.ts b/apps/api-languages/src/app/modules/language/language.resolver.ts new file mode 100644 index 00000000000..9ac759123e9 --- /dev/null +++ b/apps/api-languages/src/app/modules/language/language.resolver.ts @@ -0,0 +1,32 @@ +import { Resolver, Query, Args, ResolveField, Parent } from '@nestjs/graphql' +import { KeyAsId, TranslationField } from '@core/nest/decorators' +import { Language } from '../../__generated__/graphql' +import { LanguageService } from './language.service' + +@Resolver('Language') +export class LanguageResolver { + constructor(private readonly languageService: LanguageService) {} + + @Query() + @KeyAsId() + async languages( + @Args('page') page: number, + @Args('limit') limit: number + ): Promise { + return await this.languageService.getAll(page, limit) + } + + @Query() + @KeyAsId() + async language(@Args('id') _key: string): Promise { + return await this.languageService.get(_key) + } + + @ResolveField() + @TranslationField('name') + name( + @Parent() language, + @Args('languageId') languageId?: string, + @Args('primary') primary?: boolean + ): void {} +} diff --git a/apps/api-languages/src/app/modules/language/language.service.spec.ts b/apps/api-languages/src/app/modules/language/language.service.spec.ts new file mode 100644 index 00000000000..d6b8b4e37db --- /dev/null +++ b/apps/api-languages/src/app/modules/language/language.service.spec.ts @@ -0,0 +1,83 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { Database } from 'arangojs' +import { DeepMockProxy, mockDeep } from 'jest-mock-extended' +import { + mockCollectionSaveResult, + mockCollectionRemoveResult, + mockDbQueryResult +} from '@core/nest/database' +import { DocumentCollection } from 'arangojs/collection' +import { LanguageService } from './language.service' + +describe('LanguageService', () => { + let service: LanguageService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LanguageService, + { + provide: 'DATABASE', + useFactory: () => mockDeep() + } + ] + }).compile() + + service = module.get(LanguageService) + service.collection = mockDeep() + }) + + const language = { + _key: '1', + bscp47: 'TEC', + iso3: 'TEC', + nameNative: 'Teke, Central', + name: [ + { + value: 'Teke, Central', + languageId: '529', + primary: true + } + ] + } + + describe('getAll', () => { + beforeEach(() => { + ;(service.db as DeepMockProxy).query.mockReturnValue( + mockDbQueryResult(service.db, [language, language]) + ) + }) + + it('should retun an array of languages', async () => { + expect(await service.getAll(1, 2)).toEqual([language, language]) + }) + }) + + describe('save', () => { + beforeEach(() => { + ;( + service.collection as DeepMockProxy + ).save.mockReturnValue( + mockCollectionSaveResult(service.collection, language) + ) + }) + + it('should return a language', async () => { + expect(await service.save(language)).toEqual(language) + }) + }) + + describe('remove', () => { + beforeEach(() => { + ;( + service.collection as DeepMockProxy + ).remove.mockReturnValue( + mockCollectionRemoveResult(service.collection, language) + ) + }) + + it('should return a language', async () => { + expect(await service.remove('1')).toEqual(language) + }) + }) +}) diff --git a/apps/api-languages/src/app/modules/language/language.service.ts b/apps/api-languages/src/app/modules/language/language.service.ts new file mode 100644 index 00000000000..d73718dca42 --- /dev/null +++ b/apps/api-languages/src/app/modules/language/language.service.ts @@ -0,0 +1,19 @@ +import { BaseService } from '@core/nest/database' +import { Injectable } from '@nestjs/common' +import { aql } from 'arangojs' +import { DocumentCollection } from 'arangojs/collection' + +@Injectable() +export class LanguageService extends BaseService { + collection: DocumentCollection = this.db.collection('languages') + + async getAll(page = 1, limit = 1000): Promise { + const offset = limit * (page - 1) + const res = await this.db.query(aql` + FOR item IN ${this.collection} + LIMIT ${offset}, ${limit} + RETURN item + `) + return await res.all() + } +} diff --git a/apps/api-languages/src/app/modules/translation/translation.graphql b/apps/api-languages/src/app/modules/translation/translation.graphql new file mode 100644 index 00000000000..8472ada3077 --- /dev/null +++ b/apps/api-languages/src/app/modules/translation/translation.graphql @@ -0,0 +1,5 @@ +type Translation { + value: String! + language: Language! + primary: Boolean! +} diff --git a/apps/api-languages/src/app/modules/translation/translation.module.ts b/apps/api-languages/src/app/modules/translation/translation.module.ts new file mode 100644 index 00000000000..970de434235 --- /dev/null +++ b/apps/api-languages/src/app/modules/translation/translation.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '@core/nest/database' +import { LanguageService } from '../language/language.service' +import { TranslationResolver } from './translation.resolver' + +@Module({ + imports: [DatabaseModule], + providers: [LanguageService, TranslationResolver], + exports: [TranslationResolver] +}) +export class TranslationModule {} diff --git a/apps/api-languages/src/app/modules/translation/translation.resolver.spec.ts b/apps/api-languages/src/app/modules/translation/translation.resolver.spec.ts new file mode 100644 index 00000000000..bd687b4a9a9 --- /dev/null +++ b/apps/api-languages/src/app/modules/translation/translation.resolver.spec.ts @@ -0,0 +1,32 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { LanguageService } from '../language/language.service' +import { TranslationResolver } from './translation.resolver' + +describe('Translation Resolver', () => { + let resolver: TranslationResolver + + const translation = [ + { + value: 'Teke, Central', + languageId: '529', + primary: true + } + ] + + beforeEach(async () => { + const languageService = { + provide: LanguageService, + useFactory: () => ({ + get: jest.fn(() => translation) + }) + } + const module: TestingModule = await Test.createTestingModule({ + providers: [TranslationResolver, languageService] + }).compile() + resolver = module.get(TranslationResolver) + }) + + it('should return the translation', async () => { + expect(await resolver.language(translation)).toEqual(translation) + }) +}) diff --git a/apps/api-languages/src/app/modules/translation/translation.resolver.ts b/apps/api-languages/src/app/modules/translation/translation.resolver.ts new file mode 100644 index 00000000000..706ded13ff8 --- /dev/null +++ b/apps/api-languages/src/app/modules/translation/translation.resolver.ts @@ -0,0 +1,15 @@ +import { KeyAsId } from '@core/nest/decorators' +import { Resolver, ResolveField, Parent } from '@nestjs/graphql' +import { Language } from '../../__generated__/graphql' +import { LanguageService } from '../language/language.service' + +@Resolver('Translation') +export class TranslationResolver { + constructor(private readonly languageService: LanguageService) {} + + @ResolveField() + @KeyAsId() + async language(@Parent() translation): Promise { + return await this.languageService.get(translation.languageId) + } +} diff --git a/apps/api-languages/src/assets/.gitkeep b/apps/api-languages/src/assets/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/api-languages/src/environments/environment.prod.ts b/apps/api-languages/src/environments/environment.prod.ts new file mode 100644 index 00000000000..bc0327dbebd --- /dev/null +++ b/apps/api-languages/src/environments/environment.prod.ts @@ -0,0 +1,3 @@ +export const environment = { + production: true +} diff --git a/apps/api-languages/src/environments/environment.ts b/apps/api-languages/src/environments/environment.ts new file mode 100644 index 00000000000..b676dc71d19 --- /dev/null +++ b/apps/api-languages/src/environments/environment.ts @@ -0,0 +1,3 @@ +export const environment = { + production: false +} diff --git a/apps/api-languages/src/generate-typings.ts b/apps/api-languages/src/generate-typings.ts new file mode 100644 index 00000000000..9d9ef20d879 --- /dev/null +++ b/apps/api-languages/src/generate-typings.ts @@ -0,0 +1,20 @@ +import { join } from 'path' +import { GraphQLDefinitionsFactory } from '@nestjs/graphql' + +const definitionsFactory = new GraphQLDefinitionsFactory() +definitionsFactory + .generate({ + federation: true, + typePaths: [join(process.cwd(), 'apps/api-languages/src/app/**/*.graphql')], + path: join( + process.cwd(), + 'apps/api-languages/src/app/__generated__/graphql.ts' + ), + outputAs: 'class', + watch: true, + emitTypenameField: true, + customScalarTypeMapping: { + DateTime: 'String' + } + }) + .catch((err) => console.log(err)) diff --git a/apps/api-languages/src/main.ts b/apps/api-languages/src/main.ts new file mode 100644 index 00000000000..f48950768fa --- /dev/null +++ b/apps/api-languages/src/main.ts @@ -0,0 +1,15 @@ +import { Logger } from '@nestjs/common' +import { NestFactory } from '@nestjs/core' +import { AppModule } from './app/app.module' + +async function bootstrap(): Promise { + const port = process.env.PORT ?? '4003' + const app = await NestFactory.create(AppModule) + await app.listen(port, () => { + Logger.log('Listening at http://localhost:' + port + '/graphql') + }) +} + +bootstrap().catch((err) => { + console.log(err) +}) diff --git a/apps/api-languages/tsconfig.app.json b/apps/api-languages/tsconfig.app.json new file mode 100644 index 00000000000..0a067b3bb5c --- /dev/null +++ b/apps/api-languages/tsconfig.app.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["node"], + "emitDecoratorMetadata": true, + "target": "es2015" + }, + "exclude": ["**/*.spec.ts"], + "include": ["**/*.ts"] +} diff --git a/apps/api-languages/tsconfig.json b/apps/api-languages/tsconfig.json new file mode 100644 index 00000000000..63dbe35fb28 --- /dev/null +++ b/apps/api-languages/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/apps/api-languages/tsconfig.spec.json b/apps/api-languages/tsconfig.spec.json new file mode 100644 index 00000000000..29efa430b2b --- /dev/null +++ b/apps/api-languages/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/libs/nest/decorators/src/lib/TranslationField/TranslationField.spec.ts b/libs/nest/decorators/src/lib/TranslationField/TranslationField.spec.ts new file mode 100644 index 00000000000..1e0ca767d91 --- /dev/null +++ b/libs/nest/decorators/src/lib/TranslationField/TranslationField.spec.ts @@ -0,0 +1,100 @@ +import type { Translation } from './TranslationField' +import { TranslationField } from '.' + +describe('TranslationField', () => { + describe('when array of translations', () => { + const parent = { + name: [ + { + value: '普通話', + primary: true, + languageId: '20615' + }, + { + value: 'Chinese, Mandarin', + primary: false, + languageId: '529' + } + ] + } + + class Translatable { + @TranslationField('name') + name( + _parent: { name: Translation[] }, + _languageId?: string, + _primary?: boolean + ): void {} + } + + it('should return translations', () => { + expect(new Translatable().name(parent)).toEqual(parent.name) + }) + + it('should return translations filtered by languageId', () => { + expect(new Translatable().name(parent, '529')).toEqual([parent.name[1]]) + }) + + it('should return translations filtered by primary', () => { + expect(new Translatable().name(parent, undefined, true)).toEqual([ + parent.name[0] + ]) + }) + + it('should return tranlsations filtered by languageId or primary', () => { + expect(new Translatable().name(parent, '529', true)).toEqual(parent.name) + }) + }) + + describe('when array of array of translations', () => { + const parent = { + studyQuestions: [ + [ + { + value: '普通話', + primary: true, + languageId: '20615' + }, + { + value: 'Chinese, Mandarin', + primary: false, + languageId: '529' + } + ] + ] + } + + class Translatable { + @TranslationField('studyQuestions') + studyQuestions( + _parent: { studyQuestions: Translation[][] }, + _languageId?: string, + _primary?: boolean + ): void {} + } + + it('should return array of translations', () => { + expect(new Translatable().studyQuestions(parent)).toEqual( + parent.studyQuestions + ) + }) + + it('should return array of translations filtered by languageId', () => { + expect(new Translatable().studyQuestions(parent, '529')).toEqual([ + [parent.studyQuestions[0][1]] + ]) + }) + + it('should return array of translations filtered by primary', () => { + expect( + new Translatable().studyQuestions(parent, undefined, true) + ).toEqual([[parent.studyQuestions[0][0]]]) + }) + + it('should return array of tranlsations filtered by languageId or primary', () => { + expect(new Translatable().studyQuestions(parent, '529', true)).toEqual( + parent.studyQuestions + ) + }) + }) +}) diff --git a/libs/nest/decorators/src/lib/TranslationField/TranslationField.ts b/libs/nest/decorators/src/lib/TranslationField/TranslationField.ts new file mode 100644 index 00000000000..1d4e8410610 --- /dev/null +++ b/libs/nest/decorators/src/lib/TranslationField/TranslationField.ts @@ -0,0 +1,52 @@ +export interface Translation { + languageId: string + primary: boolean + value: string +} + +export function TranslationField( + name: string +): ( + _target: unknown, + _propertyKey: string, + descriptor: PropertyDescriptor +) => void { + return ( + _target: unknown, + _propertyKey: string, + descriptor: PropertyDescriptor + ) => { + descriptor.value = function ( + parent: { [key: string]: Translation[][] | Translation[] }, + languageId?: string, + primary?: boolean + ) { + const translations = parent[name] + if (Array.isArray(translations[0])) { + return (translations as Translation[][]).map((translations) => + filterTranslations(translations, languageId, primary) + ) + } else { + return filterTranslations( + translations as Translation[], + languageId, + primary + ) + } + } + } +} + +function filterTranslations( + translations: Translation[], + languageId?: string, + primary?: boolean +): Translation[] { + if (translations == null || (languageId == null && primary == null)) + return translations + + return translations.filter( + ({ languageId: _languageId, primary: _primary }) => + _languageId === languageId || _primary === primary + ) +} diff --git a/libs/nest/decorators/src/lib/TranslationField/index.ts b/libs/nest/decorators/src/lib/TranslationField/index.ts new file mode 100644 index 00000000000..0a56f749eeb --- /dev/null +++ b/libs/nest/decorators/src/lib/TranslationField/index.ts @@ -0,0 +1 @@ +export { TranslationField } from './TranslationField' diff --git a/libs/nest/decorators/src/lib/decorators.ts b/libs/nest/decorators/src/lib/decorators.ts index 30e64f8ae8d..033eb08cf77 100644 --- a/libs/nest/decorators/src/lib/decorators.ts +++ b/libs/nest/decorators/src/lib/decorators.ts @@ -3,6 +3,8 @@ import { get, has, omit } from 'lodash' import { GqlExecutionContext } from '@nestjs/graphql' import { AuthenticationError } from 'apollo-server-errors' +export { TranslationField } from './TranslationField' + interface TransformObject { _key?: string id?: string diff --git a/workspace.json b/workspace.json index 19c52b4c185..b3eb8be84ad 100644 --- a/workspace.json +++ b/workspace.json @@ -3,6 +3,7 @@ "projects": { "api-gateway": "apps/api-gateway", "api-journeys": "apps/api-journeys", + "api-languages": "apps/api-languages", "api-users": "apps/api-users", "apollo-logging": "libs/apollo/logging", "journeys": "apps/journeys",