/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { classToPlain } from 'class-transformer' import { debug, Debugger } from 'debug' import { isBoolean, isEmpty, isNil, isNumber, isObject, isString, pick } from 'lodash' import { HasuraExceptionError } from '../errors' import { HttpService } from '../services/http' import { isCountryCode, isUUID } from '../utils' export type FieldOptions = { field: string type: string } export type GraphQLError = { extensions: { path: string; code: string } message: string } export type GraphQLResponse = { data: { [key: string]: T } errors: Array } type ScheduledEventRetryConfig = { retriesQuantity?: number retryInterval?: number timeout?: number toleranceToDelivery?: number } export abstract class BaseRepository { protected debug: Debugger = debug(this.constructor.name) constructor( protected readonly httpService: HttpService, protected readonly table: string, protected readonly primaryKeyType?: string, ) {} protected get endpoint(): string { return `${globalThis.secret.hasura_graphql.endpoint}${globalThis.secret.hasura_graphql.path}` } async save(model: any, fields: Array): Promise { const obj = classToPlain(model) await this.fetchGraphQL({ body: { query: this.makeUpdateFieldsMutation(obj, fields), variables: pick(obj, [...fields.map(field => this.getFieldName(field)), 'id']), }, }) } async delete(id: string, args?: { returning: Array }): Promise { const { data } = await this.fetchGraphQL({ body: { query: this.makeDeleteMutation(args?.returning ?? []), variables: { id }, }, }) return data[`delete_${this.table}_by_pk`] } protected async fetchSQL(sql: string): Promise> { const body = { type: 'run_sql', args: { source: 'default', sql, read_only: true, }, } const { result, internal, error } = await this.httpService.post( `${globalThis.secret.hasura_graphql.endpoint}/v2/query`, body, { headers: this.defaultHeaders(), name: this.constructor.name, }, ) if (error) throw new Error(internal.error.message) const columns = (result as Array).shift() return ( result?.map((row: any) => columns.reduce( (previous: Record, column: string, index: number) => ({ ...previous, [column]: row[index] }), {}, ), ) || [] ) } protected async fetchGraphQL({ body, headers, language, throwErrors = true, }: { body: any headers?: any language?: string throwErrors?: boolean }): Promise { headers = { ...this.defaultHeaders({ language }), ...headers } const response = await this.httpService.post(this.endpoint, body, { headers, name: this.constructor.name, }) if (response?.errors && throwErrors) { if (response.errors.length > 1) this.debug.extend('error:many')(response.errors) throw new HasuraExceptionError(response['errors'][0] as HasuraExceptionError) } return (response as any) as T } protected createScheduledEvent( webhook: string, schedule_at: Date, comment?: string, payload?: any, headers?: Array, retryConfig?: ScheduledEventRetryConfig, ): Promise { const body = { type: 'create_scheduled_event', args: { webhook, schedule_at: schedule_at.toISOString(), payload, headers, comment, retry_conf: { num_retries: retryConfig?.retriesQuantity || 0, retry_interval_seconds: retryConfig?.retryInterval || 0, timeout_seconds: retryConfig?.timeout || 30, tolerance_seconds: retryConfig?.toleranceToDelivery || 21600, }, }, } return this.httpService.post(`${globalThis.secret.hasura_graphql.endpoint}/v1/query`, body, { headers: this.defaultHeaders(), name: this.constructor.name, }) } protected defaultHeaders(options?: { language?: string }): { [key: string]: any } { return { 'Content-Type': 'application/json', 'x-hasura-admin-secret': globalThis.secret.hasura_graphql.admin_secret, ...(!isNil(options?.language) ? { 'x-hasura-language': this.parseLanguageCode(options?.language) } : {}), ...(!isNil(options?.language) ? { 'x-hasura-locale': options.language.toLowerCase() } : {}), } } private parseLanguageCode(code: string): string { if (isCountryCode(code)) return code.split('_')[0].toLowerCase() } private makeUpdateFieldsMutation(model: any, fields: Array): string { fields = fields.filter(field => field !== 'id') return `mutation update($id: ${(this.primaryKeyType || this.typeOf(model, 'id')).replace('!', '')}!, ${fields .map(field => `\$${this.getFieldName(field)}: ${this.typeOf(model, field)}`) .join(',')}) { model: update_${this.table}_by_pk(pk_columns: {id: $id}, _set: { ${fields .map(field => `${this.getFieldName(field)}: \$${this.getFieldName(field)}`) .join(',')} }){ id } }` } private makeDeleteMutation(returning: Array): string { return `mutation delete_${this.table}($id: uuid!) { delete_${this.table}_by_pk(id: $id) { ${isEmpty(returning) ? 'id' : `${returning.join(' ')}`} } }` } private typeOf(model: any, field: string | FieldOptions): string { if (isObject(field) && !isNil(field?.type)) return field.type const value = model[this.getFieldName(field)] if (isUUID(value)) { return 'uuid!' } if (Array.isArray(value) && isUUID(value[0])) { return '[uuid!]' } if (isString(value)) { return 'String!' } if (Array.isArray(value) && isString(value[0])) { return '[String!]' } if (isBoolean(value)) { return 'Boolean!' } if (value instanceof Date) { return 'timestamptz!' } if (isNumber(value)) { return 'Int!' } if (Array.isArray(value) && isNumber(value[0])) { return '[Int!]' } throw new Error('Type not implemented yet') } private getFieldName = (field: string | FieldOptions) => field?.['field'] || field }