Merged PR 552: dictation_serverからソースコードを複製

## 概要
[Task2977: dictation_serverからソースコードを複製](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2977)

新たに構築したdictation_functionで以下のことをできるようにしました。
・RDBへの接続
・sendgridでのメール送信
・jestでのテスト実行
※とりあえず動くことを目標としているため、DB接続のパラメータやsendgridのAPIキーなどがベタ打ちになっています。機能実装時には外出ししたファイルから読み込めるようにします。

## レビューポイント
・フォルダ構成は適切か
・RDB、sendgrid、jestに関するもので不足しているものがないか
・今後機能開発を始めるにあたり、他に必要なものがないか

## UIの変更
なし

## 動作確認状況
ローカルでjestによるテストを実施し、以下を確認
・RDBからデータが取得できる
・メールが送信され、設定したアドレスで受信できる

## 補足
なし
This commit is contained in:
oura.a 2023-11-08 00:46:26 +00:00
parent 1e4a545bf8
commit 86d17d6729
15 changed files with 7227 additions and 13 deletions

View File

@ -14,6 +14,12 @@ services:
- "8082"
environment:
- CHOKIDAR_USEPOLLING=true
networks:
- external
networks:
external:
name: omds_network
external: true
# Data Volume として永続化する
volumes:

5
dictation_function/.env Normal file
View File

@ -0,0 +1,5 @@
DB_HOST=omds-mysql
DB_PORT=3306
DB_NAME=omds
DB_USERNAME=omdsdbuser
DB_PASSWORD=omdsdbpass

View File

@ -29,7 +29,6 @@ dist
.python_packages/
# Python Environments
.env
.venv
env/
venv/
@ -45,4 +44,8 @@ __pycache__/
# Azurite artifacts
__blobstorage__
__queuestorage__
__azurite_db*__.json
__azurite_db*__.json
# credentials
credentials
.env.local

File diff suppressed because it is too large Load Diff

View File

@ -9,15 +9,41 @@
"clean": "rimraf dist",
"prestart": "npm run clean && npm run build",
"start": "func start",
"test": "echo \"No tests yet...\""
"test": "jest"
},
"dependencies": {
"@azure/functions": "^4.0.0"
"@azure/functions": "^4.0.0",
"@sendgrid/mail": "^7.7.0",
"dotenv": "^16.0.3",
"mysql2": "^2.3.3",
"typeorm": "^0.3.10"
},
"devDependencies": {
"azure-functions-core-tools": "^4.x",
"@types/jest": "^27.5.0",
"@types/node": "18.x",
"typescript": "^4.0.0",
"rimraf": "^5.0.0"
"azure-functions-core-tools": "^4.x",
"jest": "^28.0.3",
"rimraf": "^5.0.0",
"sqlite3": "^5.1.6",
"supertest": "^6.1.3",
"ts-jest": "^28.0.1",
"typescript": "^4.0.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@ -0,0 +1,61 @@
import { bigintTransformer } from '.';
describe('bigintTransformer', () => {
describe('to', () => {
it('number型を整数を表す文字列に変換できる', () => {
expect(bigintTransformer.to(0)).toBe('0');
expect(bigintTransformer.to(1)).toBe('1');
expect(bigintTransformer.to(1234567890)).toBe('1234567890');
expect(bigintTransformer.to(9007199254740991)).toBe('9007199254740991');
expect(bigintTransformer.to(-1)).toBe('-1');
});
it('少数点以下がある場合はエラーとなる', () => {
expect(() => bigintTransformer.to(1.1)).toThrowError(
'1.1 is not integer.',
);
});
it('Number.MAX_SAFE_INTEGERを超える値を変換しようとするとエラーになる', () => {
expect(() => bigintTransformer.to(9007199254740992)).toThrowError(
'value is greater than 9007199254740991.',
);
expect(() => bigintTransformer.to(9223372036854775807)).toThrowError(
'value is greater than 9007199254740991.',
);
});
});
describe('from', () => {
it('bigint型の文字列をnumber型に変換できる', () => {
expect(bigintTransformer.from('0')).toBe(0);
expect(bigintTransformer.from('1')).toBe(1);
expect(bigintTransformer.from('1234567890')).toBe(1234567890);
expect(bigintTransformer.from('-1')).toBe(-1);
});
it('Number.MAX_SAFE_INTEGERを超える値を変換しようとするとエラーになる', () => {
expect(() => bigintTransformer.from('9007199254740992')).toThrowError(
'9007199254740992 is greater than 9007199254740991.',
);
expect(() => bigintTransformer.from('9223372036854775807')).toThrowError(
'9223372036854775807 is greater than 9007199254740991.',
);
});
it('number型の場合はそのまま返す', () => {
expect(bigintTransformer.from(0)).toBe(0);
expect(bigintTransformer.from(1)).toBe(1);
expect(bigintTransformer.from(1234567890)).toBe(1234567890);
expect(bigintTransformer.from(-1)).toBe(-1);
});
it('nullの場合はそのまま返す', () => {
expect(bigintTransformer.from(null)).toBe(null);
});
it('number型に変換できない場合はエラーとなる', () => {
expect(() => bigintTransformer.from('a')).toThrowError('a is not int.');
expect(() => bigintTransformer.from('')).toThrowError(' is not int.');
expect(() => bigintTransformer.from(undefined)).toThrowError(
'undefined is not string.',
);
expect(() => bigintTransformer.from({})).toThrowError(
'[object Object] is not string.',
);
});
});
});

View File

@ -0,0 +1,57 @@
import { ValueTransformer } from 'typeorm';
// DBのbigint型をnumber型に変換するためのtransformer
// DBのBigInt型をそのまま扱うと、JSのNumber型の最大値を超えると誤差が発生するため、本来はNumber型に変換すべきではないが、
// 影響範囲を最小限に抑えるため、Number型に変換する。使用するのはAutoIncrementされるIDのみの想定のため、
// Number.MAX_SAFE_INTEGERより大きい値は現実的には発生しない想定で変換する。
export const bigintTransformer: ValueTransformer = {
from: (value: any): number | null => {
// valueがnullであればそのまま返す
if (value === null) {
return value;
}
// valueがnumber型かどうかを判定
// 利用DBによってはbigint型であってもnumber型で返ってくる場合があるため、number型の場合はそのまま返す(sqliteの場合)
if (typeof value === 'number') {
return value;
}
// valueが文字列かどうかを判定
if (typeof value !== 'string') {
throw new Error(`${value} is not string.`);
}
// 数値に変換可能な文字列かどうかを判定
if (Number.isNaN(parseInt(value))) {
throw new Error(`${value} is not int.`);
}
// 文字列ならbigintに変換
// valueが整数でない場合は値が丸められてしまうが、TypeORMのEntityの定義上、整数を表す文字列以外はありえないため、少数点は考慮しない
const bigIntValue = BigInt(value);
// bigIntValueがNumber.MAX_SAFE_INTEGERより大きいかどうかを判定
if (bigIntValue > Number.MAX_SAFE_INTEGER) {
throw new Error(`${value} is greater than ${Number.MAX_SAFE_INTEGER}.`);
}
// number型で表現できる整数であればnumber型に変換して返す
return Number(bigIntValue);
},
to: (value: any): string | null | undefined => {
// valueがnullまたはundefinedであればそのまま返す
if (value === null || value === undefined) {
return value;
}
// valueがnumber型かどうかを判定
if (typeof value !== 'number') {
throw new Error(`${value} is not number.`);
}
// valueがNumber.MAX_SAFE_INTEGERより大きいかどうかを判定
if (value > Number.MAX_SAFE_INTEGER) {
throw new Error(`value is greater than ${Number.MAX_SAFE_INTEGER}.`);
}
// valueが整数かどうかを判定
if (!Number.isInteger(value)) {
throw new Error(`${value} is not integer.`);
}
return value.toString();
},
};

View File

@ -0,0 +1,71 @@
import { v4 as uuidv4 } from "uuid";
import { DataSource } from "typeorm";
import { User } from "../../entity/user.entity";
import { Account } from "../../entity/account.entity";
import { ADMIN_ROLES, USER_ROLES } from "../../constants";
type InitialTestDBState = {
tier1Accounts: { account: Account; users: User[] }[];
tier2Accounts: { account: Account; users: User[] }[];
tier3Accounts: { account: Account; users: User[] }[];
tier4Accounts: { account: Account; users: User[] }[];
tier5Accounts: { account: Account; users: User[] }[];
};
// 上書きされたら困る項目を除外したAccount型
type OverrideAccount = Omit<
Account,
"id" | "primary_admin_user_id" | "secondary_admin_user_id" | "user"
>;
// 上書きされたら困る項目を除外したUser型
type OverrideUser = Omit<
User,
"id" | "account" | "license" | "userGroupMembers"
>;
type AccountDefault = { [K in keyof OverrideAccount]?: OverrideAccount[K] };
type UserDefault = { [K in keyof OverrideUser]?: OverrideUser[K] };
/**
* ユーティリティ: 指定したプロパティを上書きしたユーザーを作成する
* @param dataSource
* @param defaultUserValue User型と同等かつoptionalなプロパティを持つ上書き箇所指定用オブジェクト
* @returns
*/
export const makeTestUser = async (
datasource: DataSource,
defaultUserValue?: UserDefault
): Promise<User> => {
const d = defaultUserValue;
const { identifiers } = await datasource.getRepository(User).insert({
account_id: d?.account_id ?? -1,
external_id: d?.external_id ?? uuidv4(),
role: d?.role ?? `${ADMIN_ROLES.STANDARD} ${USER_ROLES.NONE}`,
author_id: d?.author_id,
accepted_eula_version: d?.accepted_eula_version ?? "1.0",
accepted_dpa_version: d?.accepted_dpa_version ?? "1.0",
email_verified: d?.email_verified ?? true,
auto_renew: d?.auto_renew ?? true,
license_alert: d?.license_alert ?? true,
notification: d?.notification ?? true,
encryption: d?.encryption ?? true,
encryption_password: d?.encryption_password,
prompt: d?.prompt ?? true,
created_by: d?.created_by ?? "test_runner",
created_at: d?.created_at ?? new Date(),
updated_by: d?.updated_by ?? "updater",
updated_at: d?.updated_at ?? new Date(),
});
const result = identifiers.pop() as User;
const user = await datasource.getRepository(User).findOne({
where: {
id: result.id,
},
});
if (!user) {
throw new Error("Unexpected null");
}
return user;
};

View File

@ -0,0 +1,263 @@
/**
*
* @const {number}
*/
export const TIERS = {
//OMDS東京
TIER1: 1,
//OMDS現地法人
TIER2: 2,
//代理店
TIER3: 3,
//販売店
TIER4: 4,
//エンドユーザー
TIER5: 5,
} as const;
/**
* East USに保存する国リスト
* @const {number}
*/
export const BLOB_STORAGE_REGION_US = ['CA', 'KY', 'US'];
/**
* Australia Eastに保存する国リスト
* @const {number}
*/
export const BLOB_STORAGE_REGION_AU = ['AU', 'NZ'];
/**
* North Europeに保存する国リスト
* @const {number}
*/
export const BLOB_STORAGE_REGION_EU = [
'AT',
'BE',
'BG',
'HR',
'CY',
'CZ',
'DK',
'EE',
'FI',
'FR',
'DE',
'GR',
'HU',
'IS',
'IE',
'IT',
'LV',
'LI',
'LT',
'LU',
'MT',
'NL',
'NO',
'PL',
'PT',
'RO',
'RS',
'SK',
'SI',
'ZA',
'ES',
'SE',
'CH',
'TR',
'GB',
];
/**
*
* @const {string[]}
*/
export const ADMIN_ROLES = {
ADMIN: 'admin',
STANDARD: 'standard',
} as const;
/**
*
* @const {string[]}
*/
export const USER_ROLES = {
NONE: 'none',
AUTHOR: 'author',
TYPIST: 'typist',
} as const;
/**
*
* @const {string[]}
*/
export const LICENSE_ISSUE_STATUS = {
ISSUE_REQUESTING: 'Issue Requesting',
ISSUED: 'Issued',
CANCELED: 'Order Canceled',
};
/**
*
* @const {string[]}
*/
export const LICENSE_TYPE = {
TRIAL: 'TRIAL',
NORMAL: 'NORMAL',
CARD: 'CARD',
} as const;
/**
*
* @const {string[]}
*/
export const LICENSE_ALLOCATED_STATUS = {
UNALLOCATED: 'Unallocated',
ALLOCATED: 'Allocated',
REUSABLE: 'Reusable',
DELETED: 'Deleted',
} as const;
/**
*
* @const {string[]}
*/
export const SWITCH_FROM_TYPE = {
NONE: 'NONE',
CARD: 'CARD',
TRIAL: 'TRIAL',
} as const;
/**
*
* @const {number}
*/
export const LICENSE_EXPIRATION_THRESHOLD_DAYS = 14;
/**
*
* @const {number}
*/
export const LICENSE_EXPIRATION_DAYS = 365;
/**
*
* @const {number}
*/
export const CARD_LICENSE_LENGTH = 20;
/**
*
* @const {string}
*/
export const OPTION_ITEM_NUM = 10;
/**
*
* @const {string[]}
*/
export const TASK_STATUS = {
UPLOADED: 'Uploaded',
PENDING: 'Pending',
IN_PROGRESS: 'InProgress',
FINISHED: 'Finished',
BACKUP: 'Backup',
} as const;
/**
*
*/
export const TASK_LIST_SORTABLE_ATTRIBUTES = [
'JOB_NUMBER',
'STATUS',
'ENCRYPTION',
'AUTHOR_ID',
'WORK_TYPE',
'FILE_NAME',
'FILE_LENGTH',
'FILE_SIZE',
'RECORDING_STARTED_DATE',
'RECORDING_FINISHED_DATE',
'UPLOAD_DATE',
'TRANSCRIPTION_STARTED_DATE',
'TRANSCRIPTION_FINISHED_DATE',
] as const;
/**
*
*/
export const SORT_DIRECTIONS = ['ASC', 'DESC'] as const;
/**
*
* NotificationHubの仕様上タグ式のOR条件で使えるタグは20個まで
* https://learn.microsoft.com/ja-jp/azure/notification-hubs/notification-hubs-tags-segment-push-message#tag-expressions
*/
export const TAG_MAX_COUNT = 20;
/**
*
*/
export const PNS = {
WNS: 'wns',
APNS: 'apns',
};
/**
*
*/
export const USER_LICENSE_STATUS = {
NORMAL: 'Normal',
NO_LICENSE: 'NoLicense',
ALERT: 'Alert',
RENEW: 'Renew',
};
/**
*
* @const {number}
*/
export const TRIAL_LICENSE_EXPIRATION_DAYS = 30;
/**
*
* @const {number}
*/
export const TRIAL_LICENSE_ISSUE_NUM = 100;
/**
* worktypeの最大登録数
* @const {number}
*/
export const WORKTYPE_MAX_COUNT = 20;
/**
* worktypeのDefault値の取りうる値
**/
export const OPTION_ITEM_VALUE_TYPE = {
DEFAULT: 'Default',
BLANK: 'Blank',
LAST_INPUT: 'LastInput',
} as const;
/**
* ADB2Cユーザのidentity.signInType
* @const {string[]}
*/
export const ADB2C_SIGN_IN_TYPE = {
EMAILADDRESS: 'emailAddress',
} as const;
/**
* MANUAL_RECOVERY_REQUIRED
* @const {string}
*/
export const MANUAL_RECOVERY_REQUIRED = '[MANUAL_RECOVERY_REQUIRED]';
/**
*
* @const {string[]}
*/
export const TERM_TYPE = {
EULA: 'EULA',
DPA: 'DPA',
} as const;

View File

@ -0,0 +1,70 @@
import { bigintTransformer } from "../common/entity";
import { User } from "./user.entity";
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from "typeorm";
@Entity({ name: "accounts" })
export class Account {
@PrimaryGeneratedColumn()
id: number;
@Column({ nullable: true, type: "bigint", transformer: bigintTransformer })
parent_account_id: number | null;
@Column()
tier: number;
@Column()
country: string;
@Column({ default: false })
delegation_permission: boolean;
@Column({ default: false })
locked: boolean;
@Column()
company_name: string;
@Column({ default: false })
verified: boolean;
@Column({ nullable: true, type: "bigint", transformer: bigintTransformer })
primary_admin_user_id: number | null;
@Column({ nullable: true, type: "bigint", transformer: bigintTransformer })
secondary_admin_user_id: number | null;
@Column({ nullable: true, type: "bigint", transformer: bigintTransformer })
active_worktype_id: number | null;
@Column({ nullable: true, type: "datetime" })
deleted_at: Date | null;
@Column({ nullable: true, type: "datetime" })
created_by: string | null;
@CreateDateColumn({
default: () => "datetime('now', 'localtime')",
type: "datetime",
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
created_at: Date;
@Column({ nullable: true, type: "datetime" })
updated_by: string | null;
@UpdateDateColumn({
default: () => "datetime('now', 'localtime')",
type: "datetime",
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at: Date;
@OneToMany(() => User, (user) => user.id)
user: User[] | null;
}

View File

@ -0,0 +1,73 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity({ name: "users" })
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
external_id: string;
@Column()
account_id: number;
@Column()
role: string;
@Column({ nullable: true, type: "varchar" })
author_id: string | null;
@Column({ nullable: true, type: "varchar" })
accepted_eula_version: string | null;
@Column({ nullable: true, type: "varchar" })
accepted_dpa_version: string | null;
@Column({ default: false })
email_verified: boolean;
@Column({ default: true })
auto_renew: boolean;
@Column({ default: true })
license_alert: boolean;
@Column({ default: true })
notification: boolean;
@Column({ default: false })
encryption: boolean;
@Column({ nullable: true, type: "varchar" })
encryption_password: string | null;
@Column({ default: false })
prompt: boolean;
@Column({ nullable: true, type: "datetime" })
deleted_at: Date | null;
@Column({ nullable: true, type: "datetime" })
created_by: string | null;
@CreateDateColumn({
default: () => "datetime('now', 'localtime')",
type: "datetime",
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
created_at: Date;
@Column({ nullable: true, type: "datetime" })
updated_by: string | null;
@UpdateDateColumn({
default: () => "datetime('now', 'localtime')",
type: "datetime",
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at: Date;
}

View File

@ -1,4 +1,8 @@
import { app, InvocationContext, Timer } from "@azure/functions";
import { DataSource } from "typeorm";
import { User } from "../entity/user.entity";
import { SendGridService } from "../sendgrid/sendgrid.service";
import * as dotenv from "dotenv";
// タイマートリガー処理のサンプルです
// TODO:開発が進んだら削除すること
@ -7,6 +11,57 @@ export async function timerTriggerExample(
context: InvocationContext
): Promise<void> {
context.log("Timer function processed request.");
dotenv.config({ path: ".env" });
const datasource = new DataSource({
type: "mysql",
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [User],
});
try {
await datasource.initialize();
const userRepository = datasource.getRepository(User); // Userエンティティに対応するリポジトリを取得
// ユーザーを検索
const users = await userRepository.find();
console.log(users);
} catch (e) {
console.error(e);
} finally {
await datasource.destroy();
}
}
// test実行確認用サンプル
// TODO:開発が進んだら削除すること
export async function testExample(datasource: DataSource): Promise<User[]> {
let users: User[];
const userRepository = datasource.getRepository(User); // Userエンティティに対応するリポジトリを取得
// ユーザーを検索
users = await userRepository.find();
return users;
}
// test実行確認用サンプル
// TODO:開発が進んだら削除すること
export async function testSendgridExample(): Promise<string> {
const sendgrid = new SendGridService();
// メールを送信
await sendgrid.sendMail(
"oura.a89@gmail.com",
process.env.MAIL_FROM,
"testMail",
"test!",
"html"
);
return "sucsess";
}
app.timer("timerTriggerExample", {

View File

@ -0,0 +1,60 @@
import sendgrid from "@sendgrid/mail";
export class SendGridService {
constructor() {
sendgrid.setApiKey(process.env.SENDGRID_API_KEY);
}
/**
*
* @param accountId ID
* @param userId ID
* @param email
* @returns
*/
async createMailContent(
accountId: number,
userId: number,
email: string
): Promise<{ subject: string; text: string; html: string }> {
return {
subject: "Verify your new account",
text: `The verification URL.`,
html: `<p>The verification URL.<p>`,
};
}
/**
*
* @param to
* @param from
* @param subject
* @param text
* @param html
* @returns mail
*/
async sendMail(
to: string,
from: string,
subject: string,
text: string,
html: string
): Promise<void> {
try {
const res = await sendgrid
.send({
from: {
email: from,
},
to: {
email: to,
},
subject: subject,
text: text,
html: html,
})
.then((v) => v[0]);
} catch (e) {
throw e;
}
}
}

View File

@ -0,0 +1,42 @@
import { DataSource } from "typeorm";
import {
testExample,
testSendgridExample,
} from "../functions/timerTriggerExample";
import { makeTestUser } from "../common/test/utility";
import * as dotenv from "dotenv";
describe("timerTriggerExample", () => {
dotenv.config({ path: ".env.local", override: true });
let source: DataSource | null = null;
beforeEach(async () => {
source = new DataSource({
type: "sqlite",
database: ":memory:",
logging: false,
entities: [__dirname + "/../../**/*.entity{.ts,.js}"],
synchronize: true, // trueにすると自動的にmigrationが行われるため注意
});
return source.initialize();
});
afterEach(async () => {
if (!source) return;
await source.destroy();
source = null;
});
it("sample test(DB)", async () => {
const count = 5;
for (let i = 0; i < count; i++) {
await makeTestUser(source);
}
const result = await testExample(source);
expect(result.length).toEqual(count);
});
it("sample test(sendgrid)", async () => {
await testSendgridExample();
});
});

View File

@ -5,6 +5,9 @@
"outDir": "dist",
"rootDir": ".",
"sourceMap": true,
"strict": false
"strict": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"esModuleInterop": true
}
}