Merged PR 130: API実装(タスク一覧取得 | admin)

## 概要
[Task1831: API実装(タスク一覧取得 | admin)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1831)

- AdminがAPIを呼び出したときの処理である、アカウント内のTask情報すべてを取得するロジックを実装

## レビュー対象外
- Transcription開始日時と終了日時が必須プロパティになっている
  - [Task一覧APIのResponseで省略可能でないといけないプロパティが必須になっている箇所を修正する](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/OMDSDictation/_workitems/edit/1956)で修正予定のため
- ユニットテストが未実装
  - [テスト実装(タスク一覧取得 | admin)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/OMDSDictation/_workitems/edit/1955)で実施予定のため
- API I/Fのstatusのバリデーションがされていない
  - [Task一覧APIのstatusの入力チェックを行うデコレータを実装する](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/OMDSDictation/_workitems/edit/1957)で実施予定のため

## レビューポイント
1. SQLの発行方法は問題なさそうか
   - ~.entityに依存関係を記述( `@OneToOne(...)` や `@OneToMany(...)` )し、TypeORMでの取得時の挙動に任せる方法でよさそうか
2. Permissionテーブル以下も含めて一括でTypeORMによるクエリビルダーに任せたかったが、他の上手くいっている構造と同じ指定をしてもSQL発行時に指定したカラム名を取ってこなくなるという問題が解決できなかったため、2回に分けて取ってくるようにしたが許容可能そうか?
3. 各テーブルでRepositoryを作ってEntityを定義し、他RepositoryからはそのRepository配下のディレクトリを参照するという形を取ってみたが、方針として問題ないか
   - 各テーブルを個別に取得したい場合があるかも?という予想があったため
4. Serviceのつくりとして問題はなさそうか(roleによる呼び分けの実装方法など)
5. RepositoryDTO→ControllerDTOの型変換が複雑であったため、専用のconvert.tsというファイルに分離したが、方針として問題なさそうか

## 動作確認状況
- ローカルで確認
This commit is contained in:
湯本 開 2023-06-12 03:50:48 +00:00
parent 1082a48fe9
commit 26098cc400
21 changed files with 548 additions and 23 deletions

View File

@ -23,7 +23,9 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"og": "openapi-generator-cli",
"openapi-format": "cat \"src/api/odms/openapi.json\" | jq -c . > \"src/api/odms/openapi.json\" && prettier --write \"src/api/odms/*.json\""
"openapi-format": "cat \"src/api/odms/openapi.json\" | jq -c . > \"src/api/odms/openapi.json\" && prettier --write \"src/api/odms/*.json\"",
"migrate:up": "sql-migrate up -config=/app/dictation_server/db/dbconfig.yml -env=local",
"migrate:down": "sql-migrate down -config=/app/dictation_server/db/dbconfig.yml -env=local"
},
"dependencies": {
"@azure/identity": "^3.1.3",

View File

@ -36,6 +36,8 @@ import { BlobstorageModule } from './gateways/blobstorage/blobstorage.module';
import { LicensesModule } from './features/licenses/licenses.module';
import { LicensesService } from './features/licenses/licenses.service';
import { LicensesController } from './features/licenses/licenses.controller';
import { CheckoutPermissionsRepositoryModule } from './repositories/checkout_permissions/checkout_permissions.repository.module';
import { UserGroupsRepositoryModule } from './repositories/user_groups/user_groups.repository.module';
import { SortCriteriaRepositoryModule } from './repositories/sort_criteria/sort_criteria.repository.module';
@Module({
@ -62,6 +64,8 @@ import { SortCriteriaRepositoryModule } from './repositories/sort_criteria/sort_
AudioFilesRepositoryModule,
AudioOptionItemsRepositoryModule,
TasksRepositoryModule,
CheckoutPermissionsRepositoryModule,
UserGroupsRepositoryModule,
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({

View File

@ -101,6 +101,13 @@ export const USER_ROLES = {
TYPIST: 'typist',
} as const;
/**
* Token.roleに配置されうる文字列リテラル型
*/
export type Roles =
| (typeof ADMIN_ROLES)[keyof typeof ADMIN_ROLES]
| (typeof USER_ROLES)[keyof typeof USER_ROLES];
/**
*
* @const {string}

View File

@ -241,6 +241,7 @@ describe('FilesService', () => {
const userRepoParam = makeDefaultUsersRepositoryMockValue();
const service = await makeFilesServiceMock(blobParam, userRepoParam, {
create: new Error(''),
getTasksFromAccountId: new Error(),
});
await expect(

View File

@ -18,6 +18,7 @@ export type UsersRepositoryMockValue = {
export type TasksRepositoryMockValue = {
create: Task | Error;
getTasksFromAccountId: { tasks: Task[]; count: number } | Error;
};
export const makeFilesServiceMock = async (
@ -148,5 +149,9 @@ export const makeDefaultTasksRepositoryMockValue =
priority: '01',
created_at: new Date(),
},
getTasksFromAccountId: {
tasks: [],
count: 0,
},
};
};

View File

@ -6,6 +6,8 @@ import {
Param,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiResponse,
@ -23,6 +25,14 @@ import {
TasksRequest,
TasksResponse,
} from './types/types';
import {
isSortDirection,
isTaskListSortableAttribute,
} from '../../common/types/sort';
import jwt from 'jsonwebtoken';
import { retrieveAuthorizationToken } from '../../common/http/helper';
import { AccessToken } from '../../common/token';
import { AuthGuard } from 'src/common/guards/auth/authguards';
@ApiTags('tasks')
@Controller('tasks')
@ -54,19 +64,32 @@ export class TasksController {
description: '音声ファイル・文字起こしタスク情報をページ指定して取得します',
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@Get()
async getTasks(
@Headers() headers,
@Req() req,
@Query() body: TasksRequest,
): Promise<TasksResponse> {
console.log(headers);
console.log(body);
return {
limit: 200,
offset: 0,
total: 0,
tasks: [],
};
const accessToken = retrieveAuthorizationToken(req);
const decodedToken = jwt.decode(accessToken, { json: true }) as AccessToken;
const { limit, offset, status } = body;
const paramName = isTaskListSortableAttribute(body.paramName)
? body.paramName
: undefined;
const direction = isSortDirection(body.direction)
? body.direction
: undefined;
const { tasks, total } = await this.taskService.getTasksFromAccountId(
decodedToken,
offset,
limit,
status?.split(',') ?? [],
paramName,
direction,
);
return { tasks, total, limit, offset };
}
@Get('next')

View File

@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { TasksService } from './tasks.service';
import { TasksController } from './tasks.controller';
import { UsersRepositoryModule } from '../../repositories/users/users.repository.module';
import { TasksRepositoryModule } from '../../repositories/tasks/tasks.repository.module';
@Module({
imports: [UsersRepositoryModule, TasksRepositoryModule],
providers: [TasksService],
controllers: [TasksController],
})

View File

@ -1,11 +1,14 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TasksService } from './tasks.service';
import { TasksRepositoryModule } from '../../repositories/tasks/tasks.repository.module';
import { UsersRepositoryModule } from '../../repositories/users/users.repository.module';
describe('TasksService', () => {
let service: TasksService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [TasksRepositoryModule, UsersRepositoryModule],
providers: [TasksService],
}).compile();

View File

@ -1,4 +1,75 @@
import { Injectable } from '@nestjs/common';
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service';
import { AccessToken } from '../../common/token';
import { Task } from './types/types';
import { createTasks } from './types/convert';
import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import {
SortDirection,
TaskListSortableAttribute,
} from '../../common/types/sort';
import { ADMIN_ROLES, USER_ROLES } from 'src/constants';
@Injectable()
export class TasksService {}
export class TasksService {
private readonly logger = new Logger(TasksService.name);
constructor(
private readonly taskRepository: TasksRepositoryService,
private readonly usersRepository: UsersRepositoryService,
) {}
// TODO: 引数にAccessTokenがあるのは不適切なのでController側で分解したい
async getTasksFromAccountId(
accessToken: AccessToken,
offset: number,
limit: number,
status: string[],
paramName?: TaskListSortableAttribute,
direction?: SortDirection,
): Promise<{ tasks: Task[]; total: number }> {
const { role, userId } = accessToken;
const roles = role.split(' '); // TODO: Roleを型で定義されているものに修正する
// パラメータが省略された場合のデフォルト値: 保存するソート条件の値の初期値と揃える
const defaultParamName: TaskListSortableAttribute = 'JOB_NUMBER';
const defaultDirection: SortDirection = 'ASC';
try {
const { account_id } = await this.usersRepository.findUserByExternalId(
userId,
);
if (roles.includes(ADMIN_ROLES.ADMIN)) {
const result = await this.taskRepository.getTasksFromAccountId(
account_id,
offset,
limit,
paramName ?? defaultParamName,
direction ?? defaultDirection,
status,
);
const tasks = createTasks(result.tasks, result.permissions);
return { tasks: tasks, total: result.count };
}
if (roles.includes(USER_ROLES.AUTHOR)) {
throw new Error(`NOT IMPLEMENTED`);
}
if (roles.includes(USER_ROLES.TYPIST)) {
throw new Error(`NOT IMPLEMENTED`);
}
throw new Error(`invalid roles: ${roles.join(',')}`);
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E000101'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View File

@ -0,0 +1,114 @@
import { Task as TaskEntity } from '../../../repositories/tasks/entity/task.entity';
import { User as UserEntity } from '../../../repositories/users/entity/user.entity';
import { UserGroup as UserGroupEntity } from '../../../repositories/user_groups/entity/user_group.entity';
import { CheckoutPermission as CheckoutPermissionEntity } from '../../../repositories/checkout_permissions/entity/checkout_permission.entity';
import { AudioOptionItem as AudioOptionItemEntity } from '../../../repositories/audio_option_items/entity/audio_option_item.entity';
import { Task, Typist } from './types';
import { AudioOptionItem } from '../../files/types/types';
// Repository側のDTOからTaskオブジェクトの一覧を構築する
export const createTasks = (
tasks: TaskEntity[],
permissions: CheckoutPermissionEntity[],
): Task[] => {
// Taskオブジェクトを構築
const convertedTasks = tasks.map((task) => {
const targets = permissions.filter(
(permission) => permission.task_id === task.id,
);
return createTask(task, targets);
});
return convertedTasks;
};
// Repository側のDTOからTaskオブジェクトを構築する
const createTask = (
task: TaskEntity,
permissions: CheckoutPermissionEntity[],
): Task => {
const { file, option_items, typist_user } = task;
if (!file) {
throw new Error('file not found.');
}
if (!option_items) {
throw new Error('option_items not found.');
}
// RepositoryDTO => ControllerDTOに変換
const optionItems = createAudioOptionItems(option_items);
// RepositoryDTO => ControllerDTOに変換
const assignees = createAssignees(permissions);
// RepositoryDTO => ControllerDTOに変換
const typist: Typist =
typist_user != null ? convertUserToTypist(typist_user) : undefined;
return {
audioFileId: task.audio_file_id,
priority: task.priority,
status: task.status,
jobNumber: task.job_number,
transcriptionFinishedDate: task.finished_at?.toISOString() ?? '', // XXX Responseの型がnullableでないとおかしいのでdevelopマージ前に修正を行う[2023/06/07 17:43]
transcriptionStartedDate: task.started_at?.toISOString() ?? '', // XXX Responseの型がnullableでないとおかしいのでdevelopマージ前に修正を行う[2023/06/07 17:43]
authorId: file.author_id,
workType: file.work_type_id,
audioCreatedDate: file.started_at.toISOString(),
audioDuration: file.duration,
audioFinishedDate: file.finished_at.toISOString(),
audioUploadedDate: file.uploaded_at.toISOString(),
audioFormat: file.audio_format,
comment: file.comment ?? '',
fileName: file.file_name,
fileSize: file.file_size,
isEncrypted: file.is_encrypted,
url: file.url,
optionItemList: optionItems,
assignees: assignees,
typist: typist,
};
};
// Repository側のDTOからAudioOptionItemオブジェクトを構築する
const createAudioOptionItems = (
optionItems: AudioOptionItemEntity[],
): AudioOptionItem[] => {
return optionItems.map((x) => {
return {
optionItemLabel: x.label,
optionItemValue: x.value,
};
});
};
// Repository側のDTOからAudioOptionItemオブジェクトを構築する
const createAssignees = (permissions: CheckoutPermissionEntity[]): Typist[] => {
return permissions.flatMap((x): Typist[] => {
if (x.user != null) {
return [convertUserToTypist(x.user)];
}
if (x.user_group != null) {
return [convertUserGroupToTypist(x.user_group)];
}
// JOINしようとしたがUserが存在しなかったというケースはSkipする
return [];
});
};
// RepositoryDTOのUserからTypistオブジェクトを生成します
const convertUserToTypist = (user: UserEntity): Typist => {
return {
typistUserId: user.id,
typistName: `USER_${user?.external_id}`, // XXX Azure AD B2Cから取得した名前を入れる
};
};
// RepositoryDTOのUserGroupからTypistオブジェクトを生成します
const convertUserGroupToTypist = (userGroup: UserGroupEntity): Typist => {
return {
typistGroupId: userGroup.id,
typistName: userGroup.name,
};
};

View File

@ -1,8 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { AudioOptionItem } from '../../../features/files/types/types';
import { IsIn, IsInt, IsOptional, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Min } from 'class-validator';
import { TASK_LIST_SORTABLE_ATTRIBUTES } from '../../../constants';
export class TasksRequest {
@ -13,6 +12,7 @@ export class TasksRequest {
})
@IsInt()
@Min(0)
@Type(() => Number)
@IsOptional()
@Type(() => Number)
limit: number;
@ -25,6 +25,7 @@ export class TasksRequest {
})
@IsInt()
@Min(0)
@Type(() => Number)
@IsOptional()
@Type(() => Number)
offset: number;

View File

@ -1,4 +1,5 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { Task } from '../../../repositories/tasks/entity/task.entity';
import { Entity, Column, PrimaryGeneratedColumn, OneToOne } from 'typeorm';
@Entity({ name: 'audio_files' })
export class AudioFile {
@ -37,4 +38,6 @@ export class AudioFile {
deleted_at?: Date;
@Column()
is_encrypted: boolean;
@OneToOne(() => Task, (task) => task.file)
task?: Task;
}

View File

@ -1,14 +1,23 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { Task } from '../../../repositories/tasks/entity/task.entity';
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
@Entity({ name: 'audio_option_items' })
export class AudioOptionItem {
@PrimaryGeneratedColumn()
id: number;
@Column()
audio_file_id: number;
@Column()
label: string;
@Column()
value: string;
@ManyToOne(() => Task, (task) => task.audio_file_id)
@JoinColumn({ name: 'audio_file_id' })
task?: Task;
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CheckoutPermission } from './entity/checkout_permission.entity';
import { CheckoutPermissionsRepositoryService } from './checkout_permissions.repository.service';
@Module({
imports: [TypeOrmModule.forFeature([CheckoutPermission])],
providers: [CheckoutPermissionsRepositoryService],
exports: [CheckoutPermissionsRepositoryService],
})
export class CheckoutPermissionsRepositoryModule {}

View File

@ -0,0 +1,7 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Injectable()
export class CheckoutPermissionsRepositoryService {
constructor(private dataSource: DataSource) {}
}

View File

@ -0,0 +1,32 @@
import { UserGroup } from '../../../repositories/user_groups/entity/user_group.entity';
import { User } from '../../../repositories/users/entity/user.entity';
import {
Entity,
Column,
PrimaryGeneratedColumn,
JoinColumn,
OneToOne,
} from 'typeorm';
@Entity({ name: 'checkout_permission' })
export class CheckoutPermission {
@PrimaryGeneratedColumn()
id: number;
@Column()
task_id: number;
@Column()
user_id?: number;
@Column()
user_group_id?: number;
@OneToOne(() => User, (user) => user.id)
@JoinColumn({ name: 'user_id' })
user?: User;
@OneToOne(() => UserGroup, (group) => group.id)
@JoinColumn({ name: 'user_group_id' })
user_group?: UserGroup;
}

View File

@ -1,4 +1,14 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { AudioOptionItem } from '../../../repositories/audio_option_items/entity/audio_option_item.entity';
import { AudioFile } from '../../../repositories/audio_files/entity/audio_file.entity';
import { User } from '../../../repositories/users/entity/user.entity';
import {
Entity,
Column,
PrimaryGeneratedColumn,
OneToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
@Entity({ name: 'tasks' })
export class Task {
@ -21,9 +31,17 @@ export class Task {
@Column({ nullable: true })
template_file_id?: number;
@Column({ nullable: true })
started_at?: number;
started_at?: Date;
@Column({ nullable: true })
finished_at?: number;
finished_at?: Date;
@Column({ type: 'timestamp' })
created_at: Date;
@OneToOne(() => AudioFile, (audiofile) => audiofile.task)
@JoinColumn({ name: 'audio_file_id' })
file?: AudioFile;
@OneToMany(() => AudioOptionItem, (option) => option.task)
option_items?: AudioOptionItem[];
@OneToOne(() => User, (user) => user.id)
@JoinColumn({ name: 'typist_user_id' })
typist_user?: User;
}

View File

@ -1,14 +1,93 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import {
DataSource,
FindOptionsOrder,
FindOptionsOrderValue,
In,
} from 'typeorm';
import { Task } from './entity/task.entity';
import { TASK_STATUS } from '../../constants/index';
import { AudioFile } from '../audio_files/entity/audio_file.entity';
import { TASK_STATUS } from '../../constants';
import { AudioOptionItem as ParamOptionItem } from '../../features/files/types/types';
import { AudioFile } from '../audio_files/entity/audio_file.entity';
import { AudioOptionItem } from '../audio_option_items/entity/audio_option_item.entity';
import { CheckoutPermission } from '../checkout_permissions/entity/checkout_permission.entity';
import {
SortDirection,
TaskListSortableAttribute,
} from '../../common/types/sort';
@Injectable()
export class TasksRepositoryService {
constructor(private dataSource: DataSource) {}
/**
* IDに紐づくTask関連情報の一覧を取得します
* @param account_id
* @param offset
* @param limit
* @param sort_criteria
* @param direction
* @param status
* @returns tasks: タスク情報 / permissions:タスクに紐づくチェックアウト権限情報 / count: offset|limitを行わなかった場合の該当タスクの合計
*/
async getTasksFromAccountId(
account_id: number,
offset: number,
limit: number,
sort_criteria: TaskListSortableAttribute,
direction: SortDirection,
status: string[],
): Promise<{
tasks: Task[];
permissions: CheckoutPermission[];
count: number;
}> {
const order = makeOrder(sort_criteria, direction);
const value = await this.dataSource.transaction(async (entityManager) => {
const taskRepo = entityManager.getRepository(Task);
// limit/offsetによらず条件に一致するすべてのレコード数を取得
const count = await taskRepo.count({
where: {
account_id: account_id,
status: In(status),
},
});
// 条件に該当するTask一覧を取得
const tasks = await taskRepo.find({
relations: {
file: true,
option_items: true,
typist_user: true,
},
where: {
account_id: account_id,
status: In(status),
},
order: order, // 引数によってOrderに使用するパラメータを変更
take: limit,
skip: offset,
});
// TODO: Task内にCheckoutPermissionを含める方法が上手くいかなかった複雑になりすぎた 原因未調査)ため、
// 確実に上手くいく方法としてQueryの分割を行ったが、本来はオブジェクトの構築はTypeORMに一任したい
const checkoutRepo = entityManager.getRepository(CheckoutPermission);
const taskIds = tasks.map((x) => x.id);
const permissions = await checkoutRepo.find({
relations: {
user: true,
user_group: true,
},
where: {
task_id: In(taskIds),
},
});
return { tasks, permissions, count };
});
return value;
}
/**
*
@ -103,3 +182,89 @@ export class TasksRepositoryService {
return createdEntity;
}
}
// ソート用オブジェクトを生成する
const makeOrder = (
sort_criteria: TaskListSortableAttribute,
direction: FindOptionsOrderValue,
): FindOptionsOrder<Task> => {
// Priorityで最優先で昇順ソートし、
// その後指定パラメータで任意順ソートし、
// 最後に順序の固定のためPrimaryKeyで昇順ソートを行う
switch (sort_criteria) {
case 'JOB_NUMBER':
return {
priority: 'ASC',
job_number: direction,
id: 'ASC',
};
case 'STATUS':
return {
priority: 'ASC',
status: direction,
id: 'ASC',
};
case 'TRANSCRIPTION_FINISHED_DATE':
return {
priority: 'ASC',
finished_at: direction,
id: 'ASC',
};
case 'TRANSCRIPTION_STARTED_DATE':
return {
priority: 'ASC',
started_at: direction,
id: 'ASC',
};
case 'AUTHOR_ID':
return {
priority: 'ASC',
file: { author_id: direction },
id: 'ASC',
};
case 'ENCRYPTION':
return {
priority: 'ASC',
file: { is_encrypted: direction },
id: 'ASC',
};
case 'FILE_LENGTH':
return {
priority: 'ASC',
file: { duration: direction },
id: 'ASC',
};
case 'FILE_NAME':
return {
priority: 'ASC',
file: { file_name: direction },
id: 'ASC',
};
case 'FILE_SIZE':
return {
priority: 'ASC',
file: { file_size: direction },
id: 'ASC',
};
case 'RECORDING_FINISHED_DATE':
return {
priority: 'ASC',
file: { finished_at: direction },
id: 'ASC',
};
case 'RECORDING_STARTED_DATE':
return {
priority: 'ASC',
file: { started_at: direction },
id: 'ASC',
};
case 'UPLOAD_DATE':
return {
priority: 'ASC',
file: { uploaded_at: direction },
id: 'ASC',
};
default:
throw new Error();
}
};

View File

@ -0,0 +1,28 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity({ name: 'user_group' })
export class UserGroup {
@PrimaryGeneratedColumn()
id: number;
@Column()
account_id: number;
@Column()
name: string;
@Column({ nullable: true })
deleted_at?: Date;
@Column()
created_by: string;
@Column({ nullable: true })
created_at?: Date;
@Column()
updated_by: string;
@Column({ nullable: true })
updated_at?: Date;
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserGroupsRepositoryService } from './user_groups.repository.service';
import { UserGroup } from './entity/user_group.entity';
@Module({
imports: [TypeOrmModule.forFeature([UserGroup])],
providers: [UserGroupsRepositoryService],
exports: [UserGroupsRepositoryService],
})
export class UserGroupsRepositoryModule {}

View File

@ -0,0 +1,7 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Injectable()
export class UserGroupsRepositoryService {
constructor(private dataSource: DataSource) {}
}