@things-factory/integration-sftp 8.0.0-beta.9 → 8.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,241 @@
1
+ import '../../sftp-s3'
2
+
3
+ import { Arg, Ctx, Directive, Mutation, Resolver } from 'type-graphql'
4
+ import { EntityManager, getConnection, In } from 'typeorm'
5
+
6
+ import { Bizplace } from '@things-factory/biz-base'
7
+ import { FulfillmentAPI, FulfillmentCenter } from '@things-factory/integration-fulfillment'
8
+ import { Domain } from '@things-factory/shell'
9
+
10
+ import { SftpAPI } from '../../controllers'
11
+ import { FAILEDDATAPATH, SFTPFILESTORAGE, SUBMITDATAPATH, SUCCESSDATAPATH } from '../../sftp-const'
12
+ import { getPermittedDirectories } from '../../util'
13
+ import { Sftp, SftpStatus } from './sftp'
14
+ import { NewSftp, SftpPatch } from './sftp-type'
15
+
16
+ @Resolver(Sftp)
17
+ export class SftpMutation {
18
+ @Directive('@transaction')
19
+ @Mutation(returns => Sftp, { description: 'To create new Sftp' })
20
+ async createSftp(@Arg('sftp') sftp: NewSftp, @Ctx() context: ResolverContext): Promise<Sftp> {
21
+ const { domain, user, tx } = context.state
22
+
23
+ return await tx.getRepository(Sftp).save({
24
+ ...sftp,
25
+ domain,
26
+ creator: user,
27
+ updater: user
28
+ })
29
+ }
30
+
31
+ @Directive('@transaction')
32
+ @Mutation(returns => Sftp, { description: 'To modify Sftp information' })
33
+ async updateSftp(
34
+ @Arg('id') id: string,
35
+ @Arg('patch') patch: SftpPatch,
36
+ @Ctx() context: ResolverContext
37
+ ): Promise<Sftp> {
38
+ const { domain, user, tx } = context.state
39
+
40
+ const repository = tx.getRepository(Sftp)
41
+ const sftp = await repository.findOne({
42
+ where: { domain: { id: domain.id }, id }
43
+ })
44
+
45
+ return await repository.save({
46
+ ...sftp,
47
+ ...patch,
48
+ updater: user
49
+ })
50
+ }
51
+
52
+ @Directive('@transaction')
53
+ @Mutation(returns => [Sftp], { description: "To modify multiple Sftps' information" })
54
+ async updateMultipleSftp(
55
+ @Arg('patches', type => [SftpPatch]) patches: SftpPatch[],
56
+ @Ctx() context: ResolverContext
57
+ ): Promise<Sftp[]> {
58
+ const { domain, user, tx } = context.state
59
+
60
+ let results = []
61
+ const _createRecords = patches.filter((patch: any) => patch.cuFlag.toUpperCase() === '+')
62
+ const _updateRecords = patches.filter((patch: any) => patch.cuFlag.toUpperCase() === 'M')
63
+ const sftpRepo = tx.getRepository(Sftp)
64
+
65
+ if (_createRecords.length > 0) {
66
+ for (let i = 0; i < _createRecords.length; i++) {
67
+ const newRecord = _createRecords[i]
68
+
69
+ const result = await sftpRepo.save({
70
+ ...newRecord,
71
+ domain,
72
+ creator: user,
73
+ updater: user
74
+ })
75
+
76
+ results.push({ ...result, cuFlag: '+' })
77
+ }
78
+ }
79
+
80
+ if (_updateRecords.length > 0) {
81
+ for (let i = 0; i < _updateRecords.length; i++) {
82
+ const newRecord = _updateRecords[i]
83
+ const sftp = await sftpRepo.findOneBy({ id: newRecord.id })
84
+
85
+ const result = await sftpRepo.save({
86
+ ...sftp,
87
+ ...newRecord,
88
+ updater: user
89
+ })
90
+
91
+ results.push({ ...result, cuFlag: 'M' })
92
+ }
93
+ }
94
+
95
+ return results
96
+ }
97
+
98
+ @Directive('@transaction')
99
+ @Mutation(returns => Boolean, { description: 'To delete Sftp' })
100
+ async deleteSftp(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<boolean> {
101
+ const { domain, tx } = context.state
102
+
103
+ await tx.getRepository(Sftp).delete({ domain: { id: domain.id }, id })
104
+ return true
105
+ }
106
+
107
+ @Directive('@transaction')
108
+ @Mutation(returns => Boolean, { description: 'To delete multiple sftps' })
109
+ async deleteSftps(@Arg('ids', type => [String]) ids: string[], @Ctx() context: ResolverContext): Promise<boolean> {
110
+ const { domain, tx } = context.state
111
+
112
+ await tx.getRepository(Sftp).delete({
113
+ domain: { id: domain.id },
114
+ id: In(ids)
115
+ })
116
+
117
+ return true
118
+ }
119
+
120
+ @Mutation(returns => Boolean, { description: 'To sync all orders from sftp' })
121
+ async syncSftpOrders(
122
+ @Arg('customerDomainId', type => String) customerDomainId: string,
123
+ @Ctx() context: ResolverContext
124
+ ): Promise<boolean> {
125
+ await getConnection().transaction(async (tx: EntityManager) => {
126
+ const customerDomain: Domain = await tx.getRepository(Domain).findOneBy({ id: customerDomainId })
127
+
128
+ const customerBizplace: Bizplace = await getCustomerBizplace(customerDomainId, tx)
129
+
130
+ const sftpUsers: Sftp[] = await tx.getRepository(Sftp).find({
131
+ where: { domain: { id: customerDomainId }, status: SftpStatus.ACTIVE },
132
+ relations: ['fulfillmentCenter']
133
+ })
134
+
135
+ for (var i = 0; i < sftpUsers.length; i++) {
136
+ const sftpUser: Sftp = sftpUsers[i]
137
+ processSftp(sftpUser, context, customerBizplace.id)
138
+ }
139
+ })
140
+
141
+ return true
142
+ }
143
+
144
+ @Directive('@transaction')
145
+ @Mutation(returns => Boolean, { description: 'Calls syncSftpOrders for all domain Ids' })
146
+ async syncAllSftpOrders(@Ctx() context: ResolverContext): Promise<Boolean> {
147
+ try {
148
+ await getConnection().transaction(async tx => {
149
+ const sftps = await tx
150
+ .getRepository(Sftp)
151
+ .createQueryBuilder('s')
152
+ .where('s.status = :status', { status: 'ACTIVE' })
153
+ .getMany()
154
+
155
+ if (sftps.length === 0) return
156
+ for (const sftp of sftps) {
157
+ const customerBizplace = await getCustomerBizplace(sftp.domainId, tx)
158
+ await processSftp(sftp, context, customerBizplace.id)
159
+ }
160
+ })
161
+ } catch (e) {
162
+ console.log(e)
163
+ }
164
+ return true
165
+ }
166
+ }
167
+
168
+ export async function processSftp(sftpUser, context, customerBizplaceId) {
169
+ const fulfilmentCenter: FulfillmentCenter = sftpUser.fulfillmentCenter
170
+ const isDevelopment: boolean = sftpUser.isDevelopment
171
+ const folderPath: string = sftpUser.folderPath
172
+ const folderType: string = isDevelopment ? 'dev' : 'prd'
173
+ let initialDataPath: string = `${sftpUser.folderPath}/${folderType}${SUBMITDATAPATH}/`
174
+ const results: any[] = await getPermittedDirectories({ path: initialDataPath }, context)
175
+ const filesDirectories: any[] = results.filter(result => result.Size > 0)
176
+
177
+ for await (let fileDirectory of filesDirectories) {
178
+ let dataPath: string = `${sftpUser.folderPath}/${folderType}${SUBMITDATAPATH}/`
179
+ let successPath: string = `${sftpUser.folderPath}/${folderType}${SUCCESSDATAPATH}/`
180
+ let failedPath: string = `${sftpUser.folderPath}/${folderType}${FAILEDDATAPATH}/`
181
+ const fileKey: string = fileDirectory.Key
182
+ const lastSlashIdx = fileKey.lastIndexOf('/')
183
+ const fileString: string = fileKey.substring(lastSlashIdx + 1, fileKey.length)
184
+ try {
185
+ const sftp: any = sftpUser
186
+ let result: any = await SftpAPI.getOutboundOrder(sftp, { folderPath, folderType, fileKey: fileString })
187
+ let isAccept: boolean = result.isAccept
188
+
189
+ if (isAccept) {
190
+ delete result.isAccept
191
+ if (result) {
192
+ let { items: releaseOrders }: any = await FulfillmentAPI.getOutboundOrders(fulfilmentCenter, {
193
+ customerBizplaceId,
194
+ refNo: result.refNo
195
+ })
196
+
197
+ if (releaseOrders) {
198
+ result.collectionOrderNo = result.collectionOrderNo + ' - ' + (releaseOrders.length + 1)
199
+ }
200
+
201
+ result.requiredDraft = false
202
+
203
+ const releaseOrder: any = await FulfillmentAPI.createOutboundOrder(fulfilmentCenter, {
204
+ customerBizplaceId,
205
+ releaseOrder: result
206
+ })
207
+
208
+ if (releaseOrder) {
209
+ let movePaths = {
210
+ source: (dataPath += fileString),
211
+ destination: (successPath += fileString)
212
+ }
213
+ await SFTPFILESTORAGE.moveFile(movePaths)
214
+ }
215
+ }
216
+ } else {
217
+ let movePaths = {
218
+ source: (dataPath += fileString),
219
+ destination: (failedPath += fileString)
220
+ }
221
+ await SFTPFILESTORAGE.moveFile(movePaths)
222
+ }
223
+ } catch (e) {
224
+ let movePaths = {
225
+ source: (dataPath += fileString),
226
+ destination: (failedPath += fileString)
227
+ }
228
+ await SFTPFILESTORAGE.moveFile(movePaths)
229
+ }
230
+ }
231
+ }
232
+
233
+ export async function getCustomerBizplace(customerDomainId, tx): Promise<Bizplace> {
234
+ const customerDomain: Domain = await tx.getRepository(Domain).findOneBy({ id: customerDomainId })
235
+
236
+ const customerBizplace: Bizplace = await tx.getRepository(Bizplace).findOne({
237
+ where: { domain: { id: customerDomain.id } }
238
+ })
239
+
240
+ return customerBizplace
241
+ }
@@ -0,0 +1,44 @@
1
+ import { Arg, Args, Ctx, FieldResolver, Query, Resolver, Root } from 'type-graphql'
2
+
3
+ import { User } from '@things-factory/auth-base'
4
+ import { convertListParams, Domain, getRepository, ListParam } from '@things-factory/shell'
5
+
6
+ import { Sftp } from './sftp'
7
+ import { SftpList } from './sftp-type'
8
+
9
+ @Resolver(Sftp)
10
+ export class SftpQuery {
11
+ @Query(returns => Sftp, { description: 'To fetch a Sftp' })
12
+ async sftp(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<Sftp> {
13
+ const { domain } = context.state
14
+
15
+ return await getRepository(Sftp).findOne({
16
+ where: { domain: { id: domain.id }, id }
17
+ })
18
+ }
19
+
20
+ @Query(returns => SftpList, { description: 'To fetch multiple Sftps' })
21
+ async sftps(@Args(type => ListParam) params: ListParam, @Ctx() context: ResolverContext): Promise<SftpList> {
22
+ const { domain } = context.state
23
+
24
+ const convertedParams = convertListParams(params, { domain })
25
+ const [items, total] = await getRepository(Sftp).findAndCount(convertedParams)
26
+
27
+ return { items, total }
28
+ }
29
+
30
+ @FieldResolver(type => Domain)
31
+ async domain(@Root() sftp: Sftp): Promise<Domain> {
32
+ return await getRepository(Domain).findOneBy({ id: sftp.domainId })
33
+ }
34
+
35
+ @FieldResolver(type => User)
36
+ async updater(@Root() sftp: Sftp): Promise<User> {
37
+ return await getRepository(User).findOneBy({ id: sftp.updaterId })
38
+ }
39
+
40
+ @FieldResolver(type => User)
41
+ async creator(@Root() sftp: Sftp): Promise<User> {
42
+ return await getRepository(User).findOneBy({ id: sftp.creatorId })
43
+ }
44
+ }
@@ -0,0 +1,78 @@
1
+ import { Field, ID, InputType, Int, ObjectType } from 'type-graphql'
2
+
3
+ import { Sftp, SftpStatus } from './sftp'
4
+
5
+ @InputType()
6
+ export class NewSftp {
7
+ @Field()
8
+ name: string
9
+
10
+ @Field({ nullable: true })
11
+ description?: string
12
+
13
+ @Field(type => SftpStatus, { nullable: true })
14
+ status?: SftpStatus
15
+
16
+ @Field({ nullable: true })
17
+ username?: string
18
+
19
+ @Field({ nullable: true })
20
+ publicKey?: string
21
+
22
+ @Field({ nullable: true })
23
+ privateKey?: string
24
+
25
+ @Field({ nullable: true })
26
+ folderPath?: string
27
+
28
+ @Field({ nullable: true })
29
+ isDevelopment?: boolean
30
+
31
+ @Field({ nullable: true })
32
+ platform?: string
33
+ }
34
+
35
+ @InputType()
36
+ export class SftpPatch {
37
+ @Field(type => ID, { nullable: true })
38
+ id?: string
39
+
40
+ @Field({ nullable: true })
41
+ name?: string
42
+
43
+ @Field({ nullable: true })
44
+ description?: string
45
+
46
+ @Field(type => SftpStatus, { nullable: true })
47
+ status?: SftpStatus
48
+
49
+ @Field({ nullable: true })
50
+ username?: string
51
+
52
+ @Field({ nullable: true })
53
+ publicKey?: string
54
+
55
+ @Field({ nullable: true })
56
+ privateKey?: string
57
+
58
+ @Field({ nullable: true })
59
+ folderPath?: string
60
+
61
+ @Field({ nullable: true })
62
+ isDevelopment?: boolean
63
+
64
+ @Field({ nullable: true })
65
+ platform?: string
66
+
67
+ @Field()
68
+ cuFlag: string
69
+ }
70
+
71
+ @ObjectType()
72
+ export class SftpList {
73
+ @Field(type => [Sftp])
74
+ items: Sftp[]
75
+
76
+ @Field(type => Int)
77
+ total: number
78
+ }
@@ -0,0 +1,143 @@
1
+ import { Field, ID, ObjectType, registerEnumType } from 'type-graphql'
2
+ import {
3
+ Column,
4
+ CreateDateColumn,
5
+ Entity,
6
+ Index,
7
+ ManyToOne,
8
+ PrimaryGeneratedColumn,
9
+ RelationId,
10
+ UpdateDateColumn
11
+ } from 'typeorm'
12
+
13
+ import { User } from '@things-factory/auth-base'
14
+ import { FulfillmentCenter } from '@things-factory/integration-fulfillment'
15
+ import { Domain } from '@things-factory/shell'
16
+
17
+ export enum SftpStatus {
18
+ ACTIVE = 'ACTIVE',
19
+ INACTIVE = 'INACTIVE',
20
+ TERMINATED = 'TERMINATED'
21
+ }
22
+
23
+ registerEnumType(SftpStatus, {
24
+ name: 'SftpStatus',
25
+ description: 'state enumeration of a sftp'
26
+ })
27
+
28
+ @Entity()
29
+ @Index('ix_sftp_0', (sftp: Sftp) => [sftp.domain, sftp.name], { unique: true })
30
+ @ObjectType({ description: 'Entity for Sftp' })
31
+ export class Sftp {
32
+ @PrimaryGeneratedColumn('uuid')
33
+ @Field(type => ID)
34
+ readonly id: string
35
+
36
+ @ManyToOne(type => Domain)
37
+ @Field(type => Domain)
38
+ domain?: Domain
39
+
40
+ @RelationId((sftp: Sftp) => sftp.domain)
41
+ domainId?: string
42
+
43
+ @Column()
44
+ @Field()
45
+ name: string
46
+
47
+ @Column({
48
+ nullable: true
49
+ })
50
+ @Field({ nullable: true })
51
+ description?: string
52
+
53
+ @Column({ nullable: true })
54
+ @Field({ nullable: true })
55
+ status?: SftpStatus
56
+
57
+ @Column({
58
+ nullable: true
59
+ })
60
+ @Field({ nullable: true })
61
+ username?: string
62
+
63
+ @Column({
64
+ nullable: true
65
+ })
66
+ @Field({ nullable: true })
67
+ publicKey?: string
68
+
69
+ @Column({
70
+ nullable: true
71
+ })
72
+ @Field({ nullable: true })
73
+ privateKey?: string
74
+
75
+ @Column({
76
+ nullable: true
77
+ })
78
+ @Field({ nullable: true })
79
+ folderPath?: string
80
+
81
+ @Column({
82
+ nullable: true
83
+ })
84
+ @Field({ nullable: true })
85
+ isDevelopment?: boolean
86
+
87
+ @Column({
88
+ nullable: true
89
+ })
90
+ @Field({ nullable: true })
91
+ platform?: string
92
+
93
+ @ManyToOne(type => FulfillmentCenter, { nullable: true })
94
+ fulfillmentCenter: FulfillmentCenter
95
+
96
+ @Column({
97
+ nullable: true
98
+ })
99
+ @Field({ nullable: true })
100
+ responseType: string
101
+
102
+ @Column({
103
+ nullable: true
104
+ })
105
+ @Field({ nullable: true })
106
+ responseFilePattern: string
107
+
108
+ @Column({
109
+ nullable: true
110
+ })
111
+ @Field({ nullable: true })
112
+ responseFileTypes: string
113
+
114
+ @Column({ nullable: true })
115
+ @Field({ nullable: true })
116
+ lastTimeSync?: Date
117
+
118
+ @CreateDateColumn()
119
+ @Field({ nullable: true })
120
+ createdAt?: Date
121
+
122
+ @UpdateDateColumn()
123
+ @Field({ nullable: true })
124
+ updatedAt?: Date
125
+
126
+ @ManyToOne(type => User, {
127
+ nullable: true
128
+ })
129
+ @Field(type => User, { nullable: true })
130
+ creator?: User
131
+
132
+ @RelationId((sftp: Sftp) => sftp.creator)
133
+ creatorId?: string
134
+
135
+ @ManyToOne(type => User, {
136
+ nullable: true
137
+ })
138
+ @Field(type => User, { nullable: true })
139
+ updater?: User
140
+
141
+ @RelationId((sftp: Sftp) => sftp.updater)
142
+ updaterId?: string
143
+ }
@@ -0,0 +1,13 @@
1
+ import { config } from '@things-factory/env'
2
+
3
+ export var SFTPFILESTORAGE: any = config.get('sftpFileStorage')
4
+ export var SUBMITDATAPATH: string = '/sob/data'
5
+ export var SUCCESSDATAPATH: string = '/sob/success'
6
+ export var FAILEDDATAPATH: string = '/sob/failed'
7
+ export var COMPLETEDATAPATH: string = '/oc/data01'
8
+ export var COMPLETESUCCESSDATAPATH: string = '/oc/success'
9
+ export var COMPLETEFAILEDDATAPATH: string = '/oc/failed'
10
+ export var SNSUBMITDATAPATH: string = '/sn/data'
11
+ export var SNSUCCESSDATAPATH: string = '/sn/success'
12
+ export var SNFAILEDDATAPATH: string = '/sn/failed'
13
+ export var BACKUPPATH: string = 'hatiosea/'
@@ -0,0 +1,92 @@
1
+ import AWS from 'aws-sdk'
2
+
3
+ import { logger } from '@things-factory/env'
4
+
5
+ import { SFTPFILESTORAGE } from './sftp-const'
6
+
7
+ const crypto = require('crypto')
8
+
9
+ const { PassThrough } = require('stream')
10
+
11
+ if (SFTPFILESTORAGE && SFTPFILESTORAGE.type == 's3') {
12
+ const S3 = new AWS.S3({
13
+ accessKeyId: SFTPFILESTORAGE.accessKeyId,
14
+ secretAccessKey: SFTPFILESTORAGE.secretAccessKey
15
+ })
16
+
17
+ SFTPFILESTORAGE.readFolders = async (params, encoding) => {
18
+ let S3params = {
19
+ Bucket: SFTPFILESTORAGE.bucketName,
20
+ Delimiter: '/',
21
+ Prefix: params.path
22
+ }
23
+
24
+ const result = await S3.listObjects(S3params).promise()
25
+
26
+ let body = result.Contents
27
+
28
+ return body
29
+ }
30
+
31
+ SFTPFILESTORAGE.readFile = async (path, encoding) => {
32
+ const result = await S3.getObject({
33
+ Bucket: SFTPFILESTORAGE.bucketName,
34
+ Key: path
35
+ }).promise()
36
+
37
+ var body = result.Body
38
+
39
+ if (encoding) {
40
+ return body.toString(encoding)
41
+ }
42
+
43
+ return body
44
+ }
45
+
46
+ SFTPFILESTORAGE.moveFile = async (path, encoding) => {
47
+ const copyResult = await S3.copyObject({
48
+ Bucket: SFTPFILESTORAGE.bucketName,
49
+ CopySource: SFTPFILESTORAGE.bucketName + '/' + path.source,
50
+ Key: path.destination
51
+ }).promise()
52
+
53
+ const copyBody = copyResult.CopyObjectResult
54
+
55
+ const deleteResult = await S3.deleteObject({
56
+ Bucket: SFTPFILESTORAGE.bucketName,
57
+ Key: path.source
58
+ }).promise()
59
+
60
+ return true
61
+ }
62
+
63
+ /* upload file */
64
+ SFTPFILESTORAGE.uploadFile = ({ stream, filename, uploadPath }) => {
65
+ const id = crypto.randomUUID()
66
+ let size: number = 0
67
+
68
+ return new Promise<{ id: string; path: string; size: number }>((resolve, reject) =>
69
+ stream
70
+ .pipe(
71
+ (() => {
72
+ var pass = new PassThrough()
73
+
74
+ S3.upload(
75
+ {
76
+ Bucket: SFTPFILESTORAGE.bucketName,
77
+ Key: uploadPath + '/' + filename,
78
+ Body: pass
79
+ },
80
+ (err, data) => (err ? reject(err) : resolve({ id, path: uploadPath, size }))
81
+ )
82
+
83
+ return pass
84
+ })()
85
+ )
86
+ .on('error', error => reject(error))
87
+ .on('data', chunk => (size += chunk.length))
88
+ )
89
+ }
90
+
91
+ logger.info('S3 Bucket Storage is Ready.')
92
+ }
@@ -0,0 +1,33 @@
1
+ import '../sftp-s3'
2
+
3
+ import { SFTPFILESTORAGE } from '../sftp-const'
4
+
5
+ const fs = require('fs')
6
+
7
+ export async function generateFiles(params: any[]) {
8
+ const fileDirectory = './uploaded-files'
9
+ for (let i = 0; i < params.length; i++) {
10
+ let param: any = params[i]
11
+ const { uploadPath, content, title } = param
12
+
13
+ if (!fs.existsSync(fileDirectory)) {
14
+ fs.mkdirSync(fileDirectory)
15
+ }
16
+
17
+ const filePath = fileDirectory + '/' + title
18
+
19
+ fs.writeFile(filePath, content, function (err) {
20
+ if (err) throw err
21
+ console.log('File is created successfully.')
22
+ })
23
+
24
+ if (uploadPath) {
25
+ const stream = fs.createReadStream(filePath)
26
+ await SFTPFILESTORAGE.uploadFile({ stream, filename: title, uploadPath })
27
+ }
28
+ }
29
+
30
+ fs.rm(fileDirectory, { recursive: true })
31
+
32
+ return true
33
+ }
@@ -0,0 +1,15 @@
1
+ import '../sftp-s3'
2
+
3
+ import { SFTPFILESTORAGE } from '../sftp-const'
4
+
5
+ export async function getPermittedDirectories(params: any, context: ResolverContext) {
6
+ const sftpDirectories: any[] = await SFTPFILESTORAGE.readFolders(params)
7
+
8
+ return sftpDirectories
9
+ }
10
+
11
+ export async function readFile(fileKey: any) {
12
+ const file: any = await SFTPFILESTORAGE.readFile(fileKey, 'utf-8')
13
+
14
+ return file
15
+ }
@@ -0,0 +1,2 @@
1
+ export * from './get-permitted-directories'
2
+ export * from './generate-files'
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../tsconfig-base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist-server",
5
+ "baseUrl": "./"
6
+ },
7
+ "include": ["./server/**/*"],
8
+ "exclude": ["**/*.spec.ts"]
9
+ }