diff --git a/products/backend/src/application/group.ts b/products/backend/src/application/group.ts new file mode 100644 index 0000000..6428d51 --- /dev/null +++ b/products/backend/src/application/group.ts @@ -0,0 +1,308 @@ +// hono +import { HTTPException } from 'hono/http-exception'; +import { Bindings } from '../types'; +// validator +import { + type JoinGroupResponseSchemaType, + type CreateGroupResponseSchemaType, + type GetGroupInfoResponseSchemaType, + type GetGroupDebtHistoryResponseSchemaType, + type GetGroupDebtHistoryResponseElementSchemaType, +} from 'validator'; +// drizzle +import { createDbConnection } from './utils/db'; +import { eq, and, isNull, isNotNull } from 'drizzle-orm'; +import { debt, group, groupMembership } from '../db/schema'; +// utils +import { validateIsGroupMember, getGroupMembers } from './utils/group'; +import { getUserInfo, getUserNameMap } from './utils/user'; + +export const createGroupUseCase = async ( + env: Bindings, + loginUserId: string, + groupName: string +): Promise => { + // データベース接続 + const db = createDbConnection(env); + + //* グループ情報を挿入 *// + const result = await db + .insert(group) + .values({ + id: crypto.randomUUID(), + name: groupName, + inviteId: `${crypto.randomUUID()}-${crypto.randomUUID()}`, + createdBy: loginUserId, + }) + .returning({ + id: group.id, + inviteId: group.inviteId, + }); + + //* グループ作成者をgroupMembershipに挿入 *// + await db.insert(groupMembership).values({ + id: crypto.randomUUID(), + groupId: result[0].id, + userId: loginUserId, + }); + + // レスポンス + return { + group_id: result[0].id, + invite_id: result[0].inviteId, + } satisfies CreateGroupResponseSchemaType; +}; + +export const joinGroupUseCase = async ( + env: Bindings, + loginUserId: string, + inviteId: string +): Promise => { + // データベース接続 + const db = createDbConnection(env); + + // invite_id から group を取得 + const groupData = await db + .select({ + id: group.id, + }) + .from(group) + .where(eq(group.inviteId, inviteId)) + .limit(1); + + // invite_id が不正な場合はエラー + if (groupData.length === 0) { + throw new HTTPException(400, { message: 'Bad Request' }); + } + + // すでに参加している場合は即座に返す + const existingMembership = await db + .select({ + groupMembershipId: groupMembership.id, + }) + .from(groupMembership) + .where(and(eq(groupMembership.groupId, groupData[0].id), eq(groupMembership.userId, loginUserId))) + .limit(1); + + if (existingMembership.length > 0) { + // レスポンス + return { + group_id: groupData[0].id, + } satisfies JoinGroupResponseSchemaType; + } + + // loginUser を invite_id の group に参加させる + const result = await db + .insert(groupMembership) + .values({ + id: crypto.randomUUID(), + groupId: groupData[0].id, + userId: loginUserId, + }) + .returning({ + groupId: groupMembership.groupId, + }); + + // レスポンス + return { + group_id: result[0].groupId, + } satisfies JoinGroupResponseSchemaType; +}; + +export const getGroupInfoUseCase = async ( + env: Bindings, + loginUserId: string, + groupId: string +): Promise => { + // データベース接続 + const db = createDbConnection(env); + + // loginUser が グループのメンバーであることを確認 + await validateIsGroupMember(db, groupId, loginUserId); + + //* body.group_id のグループ情報を取得 *// + const groupData = await db + .select({ + name: group.name, + inviteId: group.inviteId, + createdBy: group.createdBy, + }) + .from(group) + .where(eq(group.id, groupId)) + .limit(1); + + //* body.group_id のグループ作成者情報を取得 *// + // ユーザ情報を取得 + const createdByUserInfo = await getUserInfo(db, groupData[0].createdBy); + + // グループのメンバー情報を取得 + const members = (await getGroupMembers(db, groupId)).map((member) => ({ + user_id: member.id, + user_name: member.name, + })); + + // レスポンス + return { + group_name: groupData[0].name, + invite_id: groupData[0].inviteId, + created_by_id: groupData[0].createdBy, + created_by_name: createdByUserInfo.name, + members, + } satisfies GetGroupInfoResponseSchemaType; +}; + +export const getGroupDebtHistoryUseCase = async ( + env: Bindings, + loginUserId: string, + groupId: string +): Promise => { + // データベース接続 + const db = createDbConnection(env); + + // loginUser がグループのメンバーであることを確認 + await validateIsGroupMember(db, groupId, loginUserId); + + //* グループの貸し借り履歴を取得 *// + // body.group_id の貸し借り履歴を取得 (debt table) + const rawDebtData = await db + .select({ + id: debt.id, + debtorId: debt.debtorId, + creditorId: debt.creditorId, + amount: debt.amount, + description: debt.description, + occurredAt: debt.occurredAt, + deletedAt: debt.deletedAt, + deletedBy: debt.deletedBy, + }) + .from(debt) + .where(eq(debt.groupId, groupId)); + + const userIds = rawDebtData.flatMap((debtEntry) => + [debtEntry.debtorId, debtEntry.creditorId, debtEntry.deletedBy].filter((id): id is string => id !== null) + ); + + // ユーザ名の取得 + const uniqueUserIds = Array.from(new Set(userIds)); + const userNameMap = await getUserNameMap(db, uniqueUserIds); + + // 貸し借り履歴データを配列に追加 + const debtData: GetGroupDebtHistoryResponseElementSchemaType[] = rawDebtData.map((debtEntry) => { + // debt_name, creditor_name を取得 + const debtorName = userNameMap.get(debtEntry.debtorId); + const creditorName = userNameMap.get(debtEntry.creditorId); + if (debtorName === undefined || creditorName === undefined) { + throw new HTTPException(500, { message: 'Internal Server Error' }); + } + + // deleted_by_name を取得 + let deletedByName = null; + if (debtEntry.deletedBy !== null) { + // deletedByが存在する場合のみ名前を取得 + const name = userNameMap.get(debtEntry.deletedBy); + if (name === undefined) { + throw new HTTPException(500, { message: 'Internal Server Error' }); + } + deletedByName = name; + } + + return { + debt_id: debtEntry.id, + debtor_id: debtEntry.debtorId, + debtor_name: debtorName, + creditor_id: debtEntry.creditorId, + creditor_name: creditorName, + amount: debtEntry.amount, + description: debtEntry.description === null ? '' : debtEntry.description, + occurred_at: debtEntry.occurredAt, + deleted_at: + debtEntry.deletedAt + ?.toLocaleDateString('ja-JP', { + timeZone: 'Asia/Tokyo', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + .replace(/\//g, '-') || null, + deleted_by_id: debtEntry.deletedBy, + deleted_by_name: deletedByName, + }; + }); + + // レスポンス + return { + debts: debtData, + } satisfies GetGroupDebtHistoryResponseSchemaType; +}; + +export const registerGroupDebtUseCase = async ( + env: Bindings, + loginUserId: string, + groupId: string, + creditorId: string, + debtorId: string, + amount: number, + occurredAt: string, + description: string | undefined +): Promise => { + // データベース接続 + const db = createDbConnection(env); + + // loginUser が グループのメンバーであることを確認 + await validateIsGroupMember(db, groupId, loginUserId); + + // body.group_id に貸し借り履歴を追加 (debt table) + await db.insert(debt).values({ + id: crypto.randomUUID(), + groupId, + creditorId, + debtorId, + amount, + description: typeof description === 'undefined' ? null : description, + occurredAt, + }); +}; + +export const deleteGroupDebtUseCase = async ( + env: Bindings, + loginUserId: string, + groupId: string, + debtId: string +): Promise => { + // データベース接続 + const db = createDbConnection(env); + + // loginUser が グループのメンバーであることを確認 + await validateIsGroupMember(db, groupId, loginUserId); + + //* body.debt_id の貸し借りの履歴の削除 (論理削除) *// + await db + .update(debt) + .set({ + deletedBy: loginUserId, + deletedAt: new Date(), // 現在時刻の取得 (UTC) + }) + .where(and(eq(debt.id, debtId), eq(debt.groupId, groupId), isNull(debt.deletedAt))); +}; + +export const cancelGroupDebtUseCase = async ( + env: Bindings, + loginUserId: string, + groupId: string, + debtId: string +): Promise => { + // データベース接続 + const db = createDbConnection(env); + + // loginUser が グループのメンバーであることを確認 + await validateIsGroupMember(db, groupId, loginUserId); + + //* body.debt_id の貸し借りの履歴の削除の取り消し (論理削除の取り消し) *// + await db + .update(debt) + .set({ + deletedBy: null, + deletedAt: null, + }) + .where(and(eq(debt.id, debtId), eq(debt.groupId, groupId), isNotNull(debt.deletedAt))); +}; diff --git a/products/backend/src/application/info.ts b/products/backend/src/application/info.ts new file mode 100644 index 0000000..924c00f --- /dev/null +++ b/products/backend/src/application/info.ts @@ -0,0 +1,212 @@ +// hono +import { HTTPException } from 'hono/http-exception'; +import { Bindings } from '../types'; +// validator +import { + type GetInfoAboutGroupsTheUserBelongsToResponseMemberElementSchemaType, + type GetInfoAboutGroupsTheUserBelongsToResponseGroupElementSchemaType, + type GetInfoAboutGroupsTheUserBelongsToResponseSchemaType, + type GetInfoAboutUserTransactionsResponseSchemaType, + type GetInfoAboutUserTransactionsResponseTransactionElementSchemaType, +} from 'validator'; +// drizzle +import { createDbConnection } from './utils/db'; +import { eq, and, isNull, inArray, or } from 'drizzle-orm'; +import { debt, group, groupMembership } from '../db/schema'; +// types +import { TransactionType } from './utils/types'; +// utils +import { getUserNameMap } from './utils/user'; +import { getGroupMembers } from './utils/group'; + +export const getInfoAboutGroupsTheUserBelongsToUseCase = async ( + env: Bindings, + loginUserId: string +): Promise => { + // データベース接続 + const db = createDbConnection(env); + + //* ユーザが参加しているグループ情報を取得 (group table) *// + const groupData = await db + .select({ + id: group.id, + name: group.name, + createdBy: group.createdBy, + }) + .from(group) + .where( + inArray( + group.id, + db + .select({ groupId: groupMembership.groupId }) + .from(groupMembership) + .where(eq(groupMembership.userId, loginUserId)) + ) + ); + + // createdByのユーザ名の取得 + const uniqueCreatedByIds = Array.from(new Set(groupData.map((group) => group.createdBy))); + const createdByNameMap = await getUserNameMap(db, uniqueCreatedByIds); + + // グループメンバーのユーザ名の取得 + const uniqueGroupIds = Array.from(new Set(groupData.map((group) => group.id))); + const groupMembersEntries = await Promise.all( + uniqueGroupIds.map(async (groupId) => { + const members = await getGroupMembers(db, groupId); + const formattedMembers = members.map((member) => { + return { + user_id: member.id, + user_name: member.name, + } as GetInfoAboutGroupsTheUserBelongsToResponseMemberElementSchemaType; + }); + return [groupId, formattedMembers] as const; + }) + ); + const groupMembersMap: Map = new Map( + groupMembersEntries + ); + + // グループ情報の整形 + const groupInfo: GetInfoAboutGroupsTheUserBelongsToResponseGroupElementSchemaType[] = await Promise.all( + groupData.map(async (groupData) => { + // createdByのユーザ名取得 + const createdByName = createdByNameMap.get(groupData.createdBy); + if (createdByName === undefined) { + throw new HTTPException(500, { message: 'Internal Server Error' }); + } + + // グループメンバーの取得 + const members = groupMembersMap.get(groupData.id); + if (members === undefined) { + throw new HTTPException(500, { message: 'Internal Server Error' }); + } + + return { + group_id: groupData.id, + group_name: groupData.name, + created_by_id: groupData.createdBy, + created_by_name: createdByName, + members: members, + }; + }) + ); + + // レスポンス + return { + groups: groupInfo, + } satisfies GetInfoAboutGroupsTheUserBelongsToResponseSchemaType; +}; + +export const getInfoAboutUserTransactionsUseCase = async ( + env: Bindings, + loginUserId: string +): Promise => { + // データベース接続 + const db = createDbConnection(env); + + //* loginUserが貸している取引履歴を取得 *// + // マイナス n 円 + const creditorTransactions = await db + .select({ + debtorId: debt.debtorId, + amount: debt.amount, + }) + .from(debt) + .where(and(eq(debt.creditorId, loginUserId), isNull(debt.deletedAt))); + + //* loginUserが借りている取引履歴を取得 *// + // プラス n 円 + const debtorTransactions = await db + .select({ + creditorId: debt.creditorId, + amount: debt.amount, + }) + .from(debt) + .where(and(eq(debt.debtorId, loginUserId), isNull(debt.deletedAt))); + + //* 取引相手ごとに集計 *// + // 集計結果を格納するMap + // Map + const transactions: Map = new Map(); + + // 貸している取引を集計 + for (const creditorTransaction of creditorTransactions) { + const userId = creditorTransaction.debtorId; + const amount = creditorTransaction.amount; + if (!transactions.has(userId)) { + transactions.set(userId, { user_id: userId, lent_amount: 0, borrowed_amount: 0 }); + } + const existing = transactions.get(userId)!; + existing.lent_amount += amount; + } + + // 借りている取引を集計 + for (const debtorTransaction of debtorTransactions) { + const userId = debtorTransaction.creditorId; + const amount = debtorTransaction.amount; + if (!transactions.has(userId)) { + transactions.set(userId, { user_id: userId, lent_amount: 0, borrowed_amount: 0 }); + } + const existing = transactions.get(userId)!; + existing.borrowed_amount += amount; + } + + // ユーザ名の取得 + const userIds = Array.from(transactions.keys()); + const userNameMap = await getUserNameMap(db, userIds); + + // 貸し借りの合算 + const aggregatedTransactions: GetInfoAboutUserTransactionsResponseTransactionElementSchemaType[] = Array.from( + transactions.values() + ) + .map((transaction) => { + // 合算結果: netAmount = borrowed_amount - lent_amount + const netAmount = transaction.borrowed_amount - transaction.lent_amount; + + // netAmount が 0 の場合はスキップ + if (netAmount === 0) { + return null; + } + + // ユーザ名の取得 + const counterpartyName = userNameMap.get(transaction.user_id); + if (counterpartyName === undefined) { + throw new HTTPException(500, { message: 'Internal Server Error' }); + } + + return { + counterparty_id: transaction.user_id, + counterparty_name: counterpartyName, + amount: netAmount, + } as GetInfoAboutUserTransactionsResponseTransactionElementSchemaType; + }) + .filter((item): item is GetInfoAboutUserTransactionsResponseTransactionElementSchemaType => item !== null); + + // レスポンス + return { + transactions: aggregatedTransactions, + } satisfies GetInfoAboutUserTransactionsResponseSchemaType; +}; + +export const deleteInfoUserRepaymentUseCase = async ( + env: Bindings, + loginUserId: string, + counterpartyId: string +): Promise => { + // データベース接続 + const db = createDbConnection(env); + + // loginUser <-> counterparty_id 間の取引履歴を削除(deletedAt, deletedBy をセット) + await db + .update(debt) + .set({ + deletedBy: loginUserId, + deletedAt: new Date(), // 現在時刻の取得 (UTC) + }) + .where( + or( + and(eq(debt.creditorId, loginUserId), eq(debt.debtorId, counterpartyId), isNull(debt.deletedAt)), + and(eq(debt.debtorId, loginUserId), eq(debt.creditorId, counterpartyId), isNull(debt.deletedAt)) + ) + ); +}; diff --git a/products/backend/src/application/session.ts b/products/backend/src/application/session.ts new file mode 100644 index 0000000..15953f6 --- /dev/null +++ b/products/backend/src/application/session.ts @@ -0,0 +1,24 @@ +// hono +import { Bindings } from '../types'; +// validator +import { type SessionCheckResponseSchemaType } from 'validator'; +// drizzle +import { createDbConnection } from './utils/db'; +// utils +import { getUserInfo } from './utils/user'; + +export const getSessionCheckUseCase = async ( + env: Bindings, + loginUserId: string +): Promise => { + // データベース接続 + const db = createDbConnection(env); + + // ユーザ情報を取得 + const userInfo = await getUserInfo(db, loginUserId); + + return { + user_id: userInfo.id, + user_name: userInfo.name, + } satisfies SessionCheckResponseSchemaType; +}; diff --git a/products/backend/src/application/userProfile.ts b/products/backend/src/application/userProfile.ts new file mode 100644 index 0000000..f6f67f1 --- /dev/null +++ b/products/backend/src/application/userProfile.ts @@ -0,0 +1,51 @@ +// hono +import { Bindings } from '../types'; +// validator +import { type GetUserProfileResponseSchemaType } from 'validator'; +// drizzle +import { createDbConnection } from './utils/db'; +import { eq } from 'drizzle-orm'; +import { user } from '../db/auth-schema'; +import { HTTPException } from 'hono/http-exception'; + +export const getUserProfileUseCase = async ( + env: Bindings, + loginUserId: string +): Promise => { + // データベース接続 + const db = createDbConnection(env); + + //* ユーザ情報を取得 (user table) *// + const userData = await db + .select({ display_name: user.displayName }) + .from(user) + .where(eq(user.id, loginUserId)) + .limit(1); + + // ユーザが存在しない場合は例外をスロー + if (userData.length === 0) { + throw new HTTPException(404, { message: 'User not found' }); + } + + // レスポンス + return { + display_name: userData[0].display_name === null ? '' : userData[0].display_name, + } satisfies GetUserProfileResponseSchemaType; +}; + +export const updateUserProfileUseCase = async ( + env: Bindings, + loginUserId: string, + displayName: string | undefined +): Promise => { + // データベース接続 + const db = createDbConnection(env); + + //* ユーザ情報を更新 (user table) *// + await db + .update(user) + .set({ + displayName: typeof displayName === 'undefined' ? null : displayName, + }) + .where(eq(user.id, loginUserId)); +}; diff --git a/products/backend/src/application/utils/db.ts b/products/backend/src/application/utils/db.ts new file mode 100644 index 0000000..021b2cb --- /dev/null +++ b/products/backend/src/application/utils/db.ts @@ -0,0 +1,8 @@ +// drizzle +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Bindings } from '../../types'; +import { DatabaseType } from './types'; + +export const createDbConnection = (env: Bindings): DatabaseType => { + return drizzle({ connection: env.HYPERDRIVE }); +}; diff --git a/products/backend/src/application/utils/group.ts b/products/backend/src/application/utils/group.ts new file mode 100644 index 0000000..8d85ee6 --- /dev/null +++ b/products/backend/src/application/utils/group.ts @@ -0,0 +1,49 @@ +// hono +import { HTTPException } from 'hono/http-exception'; +// drizzle +import { eq, and } from 'drizzle-orm'; +import { groupMembership } from '../../db/schema'; +// types +import { DatabaseType, UserInfoType } from './types'; +// utils +import { getUserNameMap } from './user'; + +export const validateIsGroupMember = async (db: DatabaseType, groupId: string, loginUserId: string): Promise => { + // loginUser が body.group_id のグループのメンバーであることを確認 + const me = await db + .select({ + groupMembershipId: groupMembership.id, + }) + .from(groupMembership) + .where(and(eq(groupMembership.groupId, groupId), eq(groupMembership.userId, loginUserId))) + .limit(1); + + // body.group_id のグループのメンバーでない場合はエラー + if (me.length === 0) { + throw new HTTPException(400, { message: 'Bad Request' }); + } +}; + +export const getGroupMembers = async (db: DatabaseType, groupId: string): Promise => { + // グループに所属しているユーザIDを取得 + const memberUserIds = await db + .select({ userId: groupMembership.userId }) + .from(groupMembership) + .where(eq(groupMembership.groupId, groupId)); + + // グループに所属しているユーザ名を取得 + const userIds = memberUserIds.map((member) => member.userId); + const nameMap = await getUserNameMap(db, userIds); + + return memberUserIds.map((member) => { + const name = nameMap.get(member.userId); + if (typeof name === 'undefined') { + // データ整合性の問題として扱い、サーバエラーを返す + throw new HTTPException(500, { message: 'Internal Server Error' }); + } + return { + id: member.userId, + name: name, + }; + }); +}; diff --git a/products/backend/src/application/utils/types.ts b/products/backend/src/application/utils/types.ts new file mode 100644 index 0000000..bfd98e7 --- /dev/null +++ b/products/backend/src/application/utils/types.ts @@ -0,0 +1,23 @@ +// drizzle +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { type Pool } from 'pg'; + +export type DatabaseType = NodePgDatabase> & { + $client: Pool; +}; + +export type TransactionType = { + user_id: string; // 取引相手のユーザID + lent_amount: number; // 貸した金額 + borrowed_amount: number; // 借りた金額 +}; + +export type UserNameType = { + name: string; + displayName: string | null; +}; + +export type UserInfoType = { + id: string; + name: string; +}; diff --git a/products/backend/src/application/utils/user.ts b/products/backend/src/application/utils/user.ts new file mode 100644 index 0000000..281c57f --- /dev/null +++ b/products/backend/src/application/utils/user.ts @@ -0,0 +1,54 @@ +// drizzle +import { eq, inArray } from 'drizzle-orm'; +import { user } from '../../db/schema'; +// types +import { DatabaseType, UserInfoType, UserNameType } from './types'; +import { HTTPException } from 'hono/http-exception'; + +export const formatUserName = (userName: UserNameType): string => { + return userName.displayName !== null && userName.displayName.length > 0 ? userName.displayName : userName.name; +}; + +export const getUserInfo = async (db: DatabaseType, userId: string): Promise => { + // user table からユーザ名を取得 + const userNameInfo = await db + .select({ + id: user.id, + name: user.name, + displayName: user.displayName, + }) + .from(user) + .where(eq(user.id, userId)) + .limit(1); + + // ユーザが存在しない場合はエラー + if (userNameInfo.length === 0) { + throw new HTTPException(500, { message: 'Internal Server Error' }); + } + + return { + id: userNameInfo[0].id, + // ユーザ名をフォーマット + name: formatUserName({ name: userNameInfo[0].name, displayName: userNameInfo[0].displayName }), + }; +}; + +export const getUserNameMap = async (db: DatabaseType, userIds: string[]): Promise> => { + const uniqueIds = Array.from(new Set(userIds)); + if (uniqueIds.length === 0) { + return new Map(); + } + + const rows = await db + .select({ id: user.id, name: user.name, displayName: user.displayName }) + .from(user) + .where(inArray(user.id, uniqueIds)); + + // Map + const nameMap = new Map(); + for (const row of rows) { + nameMap.set(row.id, formatUserName(row)); + } + + return nameMap; +}; diff --git a/products/backend/src/presentation/api.protected.ts b/products/backend/src/presentation/api.protected.ts index 09c02c6..65d27eb 100644 --- a/products/backend/src/presentation/api.protected.ts +++ b/products/backend/src/presentation/api.protected.ts @@ -5,7 +5,7 @@ import honoFactory from './factory/hono'; // better-auth object factory import { AuthFactoryType } from './factory/auth'; // routes -import check from './routes/check'; +import sessionRoute from './routes/session'; import group from './routes/group'; import info from './routes/info'; import userProfile from './routes/userProfile'; @@ -31,7 +31,7 @@ const apiProtected = (auth: AuthFactoryType) => { }); //* endpoint registration *// - app.route('/', check); + app.route('/', sessionRoute); app.route('/', group); app.route('/', info); app.route('/', userProfile); diff --git a/products/backend/src/presentation/routes/check.ts b/products/backend/src/presentation/routes/check.ts deleted file mode 100644 index c9b5318..0000000 --- a/products/backend/src/presentation/routes/check.ts +++ /dev/null @@ -1,67 +0,0 @@ -// hono instance -import honoFactory from '../factory/hono'; -// validator -import { sessionCheckResponseSchema, type SessionCheckResponseSchemaType } from 'validator'; -// error schema -import { route } from '../share/error'; -// drizzle -import { drizzle } from 'drizzle-orm/node-postgres'; -import { eq } from 'drizzle-orm'; -import { user } from '../../db/auth-schema'; - -const hono = honoFactory(); - -//TODO: userProfileの取得 -const getUserProfile = route.createSchema( - { - path: '/api/session', - method: 'get', - description: 'セッションが有効か確認するエンドポイント', - security: [{ SessionCookie: [] }], - request: {}, - responses: { - 200: { - description: 'OK', - content: { - 'application/json': { - schema: sessionCheckResponseSchema, - }, - }, - }, - }, - }, - [401, 500] as const -); - -hono.openapi(getUserProfile, async (c) => { - const loginUser = c.get('user'); - - // データベース接続 - const db = drizzle({ connection: c.env.HYPERDRIVE }); - - // NOTE: 共通化できそう - // ユーザ名を取得 (user table) - const userName = await db - .select({ - id: user.id, - name: user.name, - displayName: user.displayName, - }) - .from(user) - .where(eq(user.id, loginUser.id)) - .limit(1); - - // レスポンス - return c.json( - { - user_id: userName[0].id, - user_name: - userName[0].displayName !== null && userName[0].displayName.length > 0 - ? userName[0].displayName - : userName[0].name, - } satisfies SessionCheckResponseSchemaType, - 200 - ); -}); - -export default hono; diff --git a/products/backend/src/presentation/routes/group.ts b/products/backend/src/presentation/routes/group.ts index 7e00079..160b56a 100644 --- a/products/backend/src/presentation/routes/group.ts +++ b/products/backend/src/presentation/routes/group.ts @@ -1,37 +1,35 @@ // hono instance -import { HTTPException } from 'hono/http-exception'; import honoFactory from '../factory/hono'; // validator import { createGroupRequestSchema, createGroupResponseSchema, - type CreateGroupResponseSchemaType, getGroupInfoRequestSchema, getGroupInfoResponseSchema, - type GetGroupInfoResponseSchemaType, joinGroupRequestSchema, joinGroupResponseSchema, - type JoinGroupResponseSchemaType, getGroupDebtHistoryRequestSchema, getGroupDebtHistoryResponseSchema, - type GetGroupDebtHistoryResponseElementSchemaType, - type GetGroupDebtHistoryResponseSchemaType, - GetGroupInfoResponseMemberElementSchemaType, registerGroupDebtRequestSchema, deleteGroupDebtRequestSchema, cancelGroupDebtRequestSchema, } from 'validator'; // error schema import { route } from '../share/error'; -// drizzle -import { drizzle } from 'drizzle-orm/node-postgres'; -import { and, eq, isNotNull, isNull } from 'drizzle-orm'; -import { group, debt, groupMembership } from '../../db/pay-crew2-schema'; -import { user } from '../../db/auth-schema'; +// application +import { + cancelGroupDebtUseCase, + createGroupUseCase, + deleteGroupDebtUseCase, + getGroupDebtHistoryUseCase, + getGroupInfoUseCase, + joinGroupUseCase, + registerGroupDebtUseCase, +} from '../../application/group'; const hono = honoFactory(); -//TODO: グループ作成エンドポイントの登録 +// NOTE: グループ作成エンドポイントの登録 const createGroupSchema = route.createSchema( { path: '/api/group/create', @@ -66,41 +64,14 @@ hono.openapi(createGroupSchema, async (c) => { const loginUser = c.get('user'); const body = c.req.valid('json'); - // データベース接続 - const db = drizzle({ connection: c.env.HYPERDRIVE }); - - //* グループ情報を挿入 *// - const result = await db - .insert(group) - .values({ - id: crypto.randomUUID(), - name: body.group_name, - inviteId: `${crypto.randomUUID()}-${crypto.randomUUID()}`, - createdBy: loginUser.id, - }) - .returning({ - id: group.id, - inviteId: group.inviteId, - }); - - //* グループ作成者をgroupMembershipに挿入 *// - await db.insert(groupMembership).values({ - id: crypto.randomUUID(), - groupId: result[0].id, - userId: loginUser.id, - }); + // ビジネスロジック呼び出し + const response = await createGroupUseCase(c.env, loginUser.id, body.group_name); // レスポンス - return c.json( - { - group_id: result[0].id, - invite_id: result[0].inviteId, - } satisfies CreateGroupResponseSchemaType, - 201 - ); + return c.json(response, 201); }); -//TODO: メンバー登録のエンドポイントの登録 +// NOTE: メンバー登録のエンドポイントの登録 const joinGroupSchema = route.createSchema( { path: '/api/group/join', @@ -132,67 +103,17 @@ const joinGroupSchema = route.createSchema( ); hono.openapi(joinGroupSchema, async (c) => { + const loginUser = c.get('user'); const body = c.req.valid('json'); - const user = c.get('user'); - - // データベース接続 - const db = drizzle({ connection: c.env.HYPERDRIVE }); - // invite_id から group を取得 - const groupData = await db - .select({ - id: group.id, - }) - .from(group) - .where(eq(group.inviteId, body.invite_id)) - .limit(1); - - // invite_id が不正な場合はエラー - if (groupData.length === 0) { - throw new HTTPException(400, { message: 'Bad Request' }); - } - - // すでに参加している場合は即座に返スす - const existingMembership = await db - .select({ - groupMembershipId: groupMembership.id, - }) - .from(groupMembership) - .where(and(eq(groupMembership.groupId, groupData[0].id), eq(groupMembership.userId, user.id))) - .limit(1); - - if (existingMembership.length > 0) { - // レスポンス - return c.json( - { - group_id: groupData[0].id, - } satisfies JoinGroupResponseSchemaType, - 201 - ); - } - - // loginUser を invite_id の group に参加させる - const result = await db - .insert(groupMembership) - .values({ - id: crypto.randomUUID(), - groupId: groupData[0].id, - userId: user.id, - }) - .returning({ - groupId: groupMembership.groupId, - }); + // ビジネスロジック呼び出し + const response = await joinGroupUseCase(c.env, loginUser.id, body.invite_id); // レスポンス - return c.json( - { - group_id: result[0].groupId, - } satisfies JoinGroupResponseSchemaType, - 201 - ); + return c.json(response, 201); }); -//TODO: 各グループ情報取得エンドポイントの登録 +// NOTE: 各グループ情報取得エンドポイントの登録 const getGroupInfoSchema = route.createSchema( { path: '/api/group/info', @@ -224,105 +145,17 @@ const getGroupInfoSchema = route.createSchema( ); hono.openapi(getGroupInfoSchema, async (c) => { - const body = c.req.valid('json'); const loginUser = c.get('user'); + const body = c.req.valid('json'); - // データベース接続 - const db = drizzle({ connection: c.env.HYPERDRIVE }); - - // NOTE: --- 共通化開始 --- - // loginUser が body.group_id のグループのメンバーであることを確認 - const me = await db - .select({ - groupMembershipId: groupMembership.id, - }) - .from(groupMembership) - .where(and(eq(groupMembership.groupId, body.group_id), eq(groupMembership.userId, loginUser.id))) - .limit(1); - - // body.group_id のグループのメンバーでない場合はエラー - if (me.length === 0) { - throw new HTTPException(400, { message: 'Bad Request' }); - } - // NOTE: --- 共通化終了 --- - - //* body.group_id のグループ情報を取得 *// - const groupData = await db - .select({ - name: group.name, - inviteId: group.inviteId, - createdBy: group.createdBy, - }) - .from(group) - .where(eq(group.id, body.group_id)) - .limit(1); - - //* body.group_id のグループ作成者情報を取得 *// - // NOTE: 共通化できそう - // ユーザ名を取得 (user table) - const createdByUserNameInfo = await db - .select({ - name: user.name, - displayName: user.displayName, - }) - .from(user) - .where(eq(user.id, groupData[0].createdBy)) - .limit(1); - - // NOTE: 共通化できそう (info.ts と group.ts で共通化) - // NOTE: --- 共通化開始 --- - //* body.group_id のメンバー情報を取得 *// - // グループメンバーのユーザーIDを取得 (group_membership table) - const memberUserIds = await db - .select({ - userId: groupMembership.userId, - }) - .from(groupMembership) - .where(eq(groupMembership.groupId, body.group_id)); - - // メンバー情報を格納する配列 - const members: GetGroupInfoResponseMemberElementSchemaType[] = []; - - for (const memberUserId of memberUserIds) { - // NOTE: 共通化できそう - // ユーザ名を取得 (user table) - const memberUserNameInfo = await db - .select({ - name: user.name, - displayName: user.displayName, - }) - .from(user) - .where(eq(user.id, memberUserId.userId)) - .limit(1); - - // メンバー情報を配列に追加 - members.push({ - user_id: memberUserId.userId, - user_name: - memberUserNameInfo[0].displayName !== null && memberUserNameInfo[0].displayName.length > 0 - ? memberUserNameInfo[0].displayName - : memberUserNameInfo[0].name, - }); - } - // NOTE: --- 共通化終了 --- + // ビジネスロジック呼び出し + const response = await getGroupInfoUseCase(c.env, loginUser.id, body.group_id); - // return response - return c.json( - { - group_name: groupData[0].name, - invite_id: groupData[0].inviteId, - created_by_id: groupData[0].createdBy, - created_by_name: - createdByUserNameInfo[0].displayName !== null && createdByUserNameInfo[0].displayName.length > 0 - ? createdByUserNameInfo[0].displayName - : createdByUserNameInfo[0].name, - members: members, - } satisfies GetGroupInfoResponseSchemaType, - 201 - ); + // レスポンス + return c.json(response, 201); }); -// TODO: 各グループの貸し借り履歴取得エンドポイントの登録 +// NOTE: 各グループの貸し借り履歴取得エンドポイントの登録 const getGroupDebtHistorySchema = route.createSchema( { path: '/api/group/debt/history', @@ -357,129 +190,14 @@ hono.openapi(getGroupDebtHistorySchema, async (c) => { const loginUser = c.get('user'); const body = c.req.valid('json'); - // データベース接続 - const db = drizzle({ connection: c.env.HYPERDRIVE }); - - // NOTE: --- 共通化開始 --- - // loginUser が body.group_id のグループのメンバーであることを確認 - const me = await db - .select({ - groupMembershipId: groupMembership.id, - }) - .from(groupMembership) - .where(and(eq(groupMembership.groupId, body.group_id), eq(groupMembership.userId, loginUser.id))) - .limit(1); - - // body.group_id のグループのメンバーでない場合はエラー - if (me.length === 0) { - throw new HTTPException(400, { message: 'Bad Request' }); - } - // NOTE: --- 共通化終了 --- - - //* グループの貸し借り履歴を取得 *// - // 貸し借り履歴を格納する配列 - let debtData: GetGroupDebtHistoryResponseElementSchemaType[] = []; - - // body.group_id の貸し借り履歴を取得 (debt table) - const rawDebtData = await db - .select({ - id: debt.id, - debtorId: debt.debtorId, - creditorId: debt.creditorId, - amount: debt.amount, - description: debt.description, - occurredAt: debt.occurredAt, - deletedAt: debt.deletedAt, - deletedBy: debt.deletedBy, - }) - .from(debt) - .where(eq(debt.groupId, body.group_id)); - - for (const debtEntry of rawDebtData) { - // debt_name を取得 - // NOTE: 共通化できそう - // ユーザ名を取得 (user table) - const debtorNameInfo = await db - .select({ - name: user.name, - displayName: user.displayName, - }) - .from(user) - .where(eq(user.id, debtEntry.debtorId)) - .limit(1); - - // creditor_name を取得 - // NOTE: 共通化できそう - // ユーザ名を取得 (user table) - const creditorNameInfo = await db - .select({ - name: user.name, - displayName: user.displayName, - }) - .from(user) - .where(eq(user.id, debtEntry.creditorId)) - .limit(1); - - // deleted_by_name を取得 - // NOTE: 共通化できそう - // ユーザ名を取得 (user table) - let deletedByNameInfo = null; - if (debtEntry.deletedBy !== null) { - deletedByNameInfo = await db - .select({ - name: user.name, - displayName: user.displayName, - }) - .from(user) - .where(eq(user.id, debtEntry.deletedBy)) - .limit(1); - } - - // 貸し借り履歴データを配列に追加 - debtData.push({ - debt_id: debtEntry.id, - debtor_id: debtEntry.debtorId, - debtor_name: - debtorNameInfo[0].displayName !== null && debtorNameInfo[0].displayName.length > 0 - ? debtorNameInfo[0].displayName - : debtorNameInfo[0].name, - creditor_id: debtEntry.creditorId, - creditor_name: - creditorNameInfo[0].displayName !== null && creditorNameInfo[0].displayName.length > 0 - ? creditorNameInfo[0].displayName - : creditorNameInfo[0].name, - amount: debtEntry.amount, - description: debtEntry.description === null ? '' : debtEntry.description, - occurred_at: debtEntry.occurredAt, - deleted_at: - debtEntry.deletedAt - ?.toLocaleDateString('ja-JP', { - timeZone: 'Asia/Tokyo', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }) - .replace(/\//g, '-') || null, - deleted_by_id: debtEntry.deletedBy, - deleted_by_name: - debtEntry.deletedBy && deletedByNameInfo - ? deletedByNameInfo[0].displayName !== null && deletedByNameInfo[0].displayName.length > 0 - ? deletedByNameInfo[0].displayName - : deletedByNameInfo[0].name - : null, - }); - } + // ビジネスロジック呼び出し + const response = await getGroupDebtHistoryUseCase(c.env, loginUser.id, body.group_id); // レスポンス - return c.json( - { - debts: debtData, - } satisfies GetGroupDebtHistoryResponseSchemaType, - 201 - ); + return c.json(response, 201); }); -// TODO: 貸し借りの履歴の追加エンドポイントの登録 +// NOTE: 貸し借りの履歴の追加エンドポイントの登録 const registerGroupDebtSchema = route.createSchema( { path: '/api/group/debt/register', @@ -509,40 +227,23 @@ hono.openapi(registerGroupDebtSchema, async (c) => { const loginUser = c.get('user'); const body = c.req.valid('json'); - // データベース接続 - const db = drizzle({ connection: c.env.HYPERDRIVE }); - - // NOTE: --- 共通化開始 --- - // loginUser が body.group_id のグループのメンバーであることを確認 - const me = await db - .select({ - groupMembershipId: groupMembership.id, - }) - .from(groupMembership) - .where(and(eq(groupMembership.groupId, body.group_id), eq(groupMembership.userId, loginUser.id))) - .limit(1); - - // body.group_id のグループのメンバーでない場合はエラー - if (me.length === 0) { - throw new HTTPException(400, { message: 'Bad Request' }); - } - // NOTE: --- 共通化終了 --- - - // body.group_id に貸し借り履歴を追加 (debt table) - await db.insert(debt).values({ - id: crypto.randomUUID(), - groupId: body.group_id, - creditorId: body.creditor_id, - debtorId: body.debtor_id, - amount: body.amount, - description: typeof body.description === 'undefined' ? null : body.description, - occurredAt: body.occurred_at, - }); + // ビジネスロジック呼び出し + await registerGroupDebtUseCase( + c.env, + loginUser.id, + body.group_id, + body.creditor_id, + body.debtor_id, + body.amount, + body.occurred_at, + body.description + ); + // レスポンス return c.body(null, 204); }); -// TODO: 貸し借りの履歴の削除エンドポイントの登録 +// NOTE: 貸し借りの履歴の削除エンドポイントの登録 const deleteGroupDebtSchema = route.createSchema( { path: '/api/group/debt/delete', @@ -572,42 +273,14 @@ hono.openapi(deleteGroupDebtSchema, async (c) => { const loginUser = c.get('user'); const body = c.req.valid('json'); - // 現在時刻の取得 (UTC) - const now = new Date(); - - // データベース接続 - const db = drizzle({ connection: c.env.HYPERDRIVE }); - - // NOTE: --- 共通化開始 --- - // loginUser が body.group_id のグループのメンバーであることを確認 - const me = await db - .select({ - groupMembershipId: groupMembership.id, - }) - .from(groupMembership) - .where(and(eq(groupMembership.groupId, body.group_id), eq(groupMembership.userId, loginUser.id))) - .limit(1); - - // body.group_id のグループのメンバーでない場合はエラー - if (me.length === 0) { - throw new HTTPException(400, { message: 'Bad Request' }); - } - // NOTE: --- 共通化終了 --- - - //* body.debt_id の貸し借りの履歴の削除 (論理削除) *// - await db - .update(debt) - .set({ - deletedBy: loginUser.id, - deletedAt: now, - }) - .where(and(eq(debt.id, body.debt_id), eq(debt.groupId, body.group_id), isNull(debt.deletedAt))); + // ビジネスロジック呼び出し + await deleteGroupDebtUseCase(c.env, loginUser.id, body.group_id, body.debt_id); // レスポンス return c.body(null, 204); }); -// TODO: 貸し借り履歴の削除の取り消しエンドポイントの登録 +// NOTE: 貸し借り履歴の削除の取り消しエンドポイントの登録 const cancelGroupDebtSchema = route.createSchema( { path: '/api/group/debt/cancel', @@ -637,33 +310,8 @@ hono.openapi(cancelGroupDebtSchema, async (c) => { const loginUser = c.get('user'); const body = c.req.valid('json'); - // データベース接続 - const db = drizzle({ connection: c.env.HYPERDRIVE }); - - // NOTE: --- 共通化開始 --- - // loginUser が body.group_id のグループのメンバーであることを確認 - const me = await db - .select({ - groupMembershipId: groupMembership.id, - }) - .from(groupMembership) - .where(and(eq(groupMembership.groupId, body.group_id), eq(groupMembership.userId, loginUser.id))) - .limit(1); - - // body.group_id のグループのメンバーでない場合はエラー - if (me.length === 0) { - throw new HTTPException(400, { message: 'Bad Request' }); - } - // NOTE: --- 共通化終了 --- - - //* body.debt_id の貸し借りの履歴の削除の取り消し (論理削除の取り消し) *// - await db - .update(debt) - .set({ - deletedBy: null, - deletedAt: null, - }) - .where(and(eq(debt.id, body.debt_id), eq(debt.groupId, body.group_id), isNotNull(debt.deletedAt))); + // ビジネスロジック呼び出し + await cancelGroupDebtUseCase(c.env, loginUser.id, body.group_id, body.debt_id); // レスポンス return c.body(null, 204); diff --git a/products/backend/src/presentation/routes/info.ts b/products/backend/src/presentation/routes/info.ts index 1d722ce..7b421ae 100644 --- a/products/backend/src/presentation/routes/info.ts +++ b/products/backend/src/presentation/routes/info.ts @@ -2,29 +2,23 @@ import honoFactory from '../factory/hono'; // validator import { - infoAboutUserTransactionsResponseSchema, - type InfoAboutUserTransactionsResponseTransactionElementSchemaType, - type InfoAboutUserTransactionsResponseSchemaType, - infoAboutGroupsTheUserBelongsToResponseSchema, - type InfoAboutGroupsTheUserBelongsToResponseMemberElementSchemaType, - type InfoAboutGroupsTheUserBelongsToResponseGroupElementSchemaType, - type InfoAboutGroupsTheUserBelongsToResponseSchemaType, + getInfoAboutUserTransactionsResponseSchema, + getInfoAboutGroupsTheUserBelongsToResponseSchema, deleteInfoAboutUserRepaymentRequestSchema, } from 'validator'; // error schema import { route } from '../share/error'; -// drizzle -import { drizzle } from 'drizzle-orm/node-postgres'; -import { and, or, eq, inArray, isNull } from 'drizzle-orm'; -import { group, debt, groupMembership } from '../../db/pay-crew2-schema'; -import { user } from '../../db/auth-schema'; -// types -import { TransactionType } from '../utils/types'; +// application +import { + getInfoAboutGroupsTheUserBelongsToUseCase, + getInfoAboutUserTransactionsUseCase, + deleteInfoUserRepaymentUseCase, +} from '../../application/info'; const hono = honoFactory(); -//TODO: ユーザが参加しているグループ一覧を返す -const infoAboutGroupsTheUserBelongsToSchema = route.createSchema( +// NOTE: ユーザが参加しているグループ一覧を返す +const getInfoAboutGroupsTheUserBelongsToSchema = route.createSchema( { path: '/api/info/group', method: 'get', @@ -36,7 +30,7 @@ const infoAboutGroupsTheUserBelongsToSchema = route.createSchema( description: 'OK', content: { 'application/json': { - schema: infoAboutGroupsTheUserBelongsToResponseSchema, + schema: getInfoAboutGroupsTheUserBelongsToResponseSchema, }, }, }, @@ -45,106 +39,18 @@ const infoAboutGroupsTheUserBelongsToSchema = route.createSchema( [401, 500] as const ); -hono.openapi(infoAboutGroupsTheUserBelongsToSchema, async (c) => { +hono.openapi(getInfoAboutGroupsTheUserBelongsToSchema, async (c) => { const loginUser = c.get('user'); - // データベース接続 - const db = drizzle({ connection: c.env.HYPERDRIVE }); - - // グループ情報格納用配列 - let groupInfo: InfoAboutGroupsTheUserBelongsToResponseGroupElementSchemaType[] = []; - - //* ユーザが参加しているグループ情報を取得 (group table) *// - const groupsData = await db - .select({ - id: group.id, - name: group.name, - createdBy: group.createdBy, - }) - .from(group) - .where( - inArray( - group.id, - db - .select({ groupId: groupMembership.groupId }) - .from(groupMembership) - .where(eq(groupMembership.userId, loginUser.id)) - ) - ); - - for (const groupData of groupsData) { - //* body.group_id のグループ作成者情報を取得 *// - // NOTE: 共通化できそう - // ユーザ名を取得 (user table) - const createdByUserNameInfo = await db - .select({ - name: user.name, - displayName: user.displayName, - }) - .from(user) - .where(eq(user.id, groupData.createdBy)) - .limit(1); - - // NOTE: --- 共通化開始 --- - //* groupData.id のメンバー情報を取得 *// - // グループメンバーのユーザーIDを取得 (group_membership table) - const memberUserIds = await db - .select({ - userId: groupMembership.userId, - }) - .from(groupMembership) - .where(eq(groupMembership.groupId, groupData.id)); - - // メンバー情報を格納する配列 - const members: InfoAboutGroupsTheUserBelongsToResponseMemberElementSchemaType[] = []; - - for (const memberUserId of memberUserIds) { - // NOTE: 共通化できそう - // ユーザ名を取得 (user table) - const userNameInfo = await db - .select({ - name: user.name, - displayName: user.displayName, - }) - .from(user) - .where(eq(user.id, memberUserId.userId)) - .limit(1); - - // メンバー情報を配列に追加 - members.push({ - user_id: memberUserId.userId, - user_name: - userNameInfo[0].displayName !== null && userNameInfo[0].displayName.length > 0 - ? userNameInfo[0].displayName - : userNameInfo[0].name, - }); - } - // NOTE: --- 共通化終了 --- - - // グループ情報を配列に追加 - groupInfo.push({ - group_id: groupData.id, - group_name: groupData.name, - created_by_id: groupData.createdBy, - created_by_name: - createdByUserNameInfo[0].displayName !== null && createdByUserNameInfo[0].displayName.length > 0 - ? createdByUserNameInfo[0].displayName - : createdByUserNameInfo[0].name, - members: members, - }); - } + // ビジネスロジック呼び出し + const response = await getInfoAboutGroupsTheUserBelongsToUseCase(c.env, loginUser.id); // レスポンス - return c.json( - { - groups: groupInfo, - } satisfies InfoAboutGroupsTheUserBelongsToResponseSchemaType, - 200 - ); + return c.json(response, 200); }); -//TODO: ユーザの貸し借りの履歴を返す -const infoAboutUserTransactionsSchema = route.createSchema( +// NOTE: ユーザの貸し借りの履歴を返す +const getInfoAboutUserTransactionsSchema = route.createSchema( { path: '/api/info/transaction', method: 'get', @@ -156,7 +62,7 @@ const infoAboutUserTransactionsSchema = route.createSchema( description: 'OK', content: { 'application/json': { - schema: infoAboutUserTransactionsResponseSchema, + schema: getInfoAboutUserTransactionsResponseSchema, }, }, }, @@ -165,101 +71,18 @@ const infoAboutUserTransactionsSchema = route.createSchema( [401, 500] as const ); -hono.openapi(infoAboutUserTransactionsSchema, async (c) => { +hono.openapi(getInfoAboutUserTransactionsSchema, async (c) => { const loginUser = c.get('user'); - // データベース接続 - const db = drizzle({ connection: c.env.HYPERDRIVE }); - - //* loginUserが貸している取引履歴を取得 *// - // マイナス n 円 - const creditorTransactions = await db - .select({ - debtorId: debt.debtorId, - amount: debt.amount, - }) - .from(debt) - .where(and(eq(debt.creditorId, loginUser.id), isNull(debt.deletedAt))); - - //* loginUserが借りている取引履歴を取得 *// - // プラス n 円 - const debtorTransactions = await db - .select({ - creditorId: debt.creditorId, - amount: debt.amount, - }) - .from(debt) - .where(and(eq(debt.debtorId, loginUser.id), isNull(debt.deletedAt))); + // ビジネスロジック呼び出し + const response = await getInfoAboutUserTransactionsUseCase(c.env, loginUser.id); - //* 取引相手ごとに集計 *// - // 集計結果を格納するMap - const transactions: Map = new Map(); - - // 貸している取引を集計 - for (const creditorTransaction of creditorTransactions) { - const userId = creditorTransaction.debtorId; - const amount = creditorTransaction.amount; - if (!transactions.has(userId)) { - transactions.set(userId, { user_id: userId, lent_amount: 0, borrowed_amount: 0 }); - } - const existing = transactions.get(userId)!; - existing.lent_amount += amount; - } - - // 借りている取引を集計 - for (const debtorTransaction of debtorTransactions) { - const userId = debtorTransaction.creditorId; - const amount = debtorTransaction.amount; - if (!transactions.has(userId)) { - transactions.set(userId, { user_id: userId, lent_amount: 0, borrowed_amount: 0 }); - } - const existing = transactions.get(userId)!; - existing.borrowed_amount += amount; - } - - // 集計結果を格納する配列 - const aggregatedTransactions: InfoAboutUserTransactionsResponseTransactionElementSchemaType[] = []; - - // 貸し借りの合算 - for (const transaction of transactions.values()) { - // 合算結果: netAmount = borrowed_amount - lent_amount - const netAmount = transaction.borrowed_amount - transaction.lent_amount; - - // NOTE: 共通化できそう - // ユーザ名を取得 (user table) - const userNameInfo = await db - .select({ - name: user.name, - displayName: user.displayName, - }) - .from(user) - .where(eq(user.id, transaction.user_id)) - .limit(1); - - // netAmount が 0 でない場合のみ配列に追加 - if (netAmount !== 0) { - aggregatedTransactions.push({ - counterparty_id: transaction.user_id, - counterparty_name: - userNameInfo[0].displayName !== null && userNameInfo[0].displayName.length > 0 - ? userNameInfo[0].displayName - : userNameInfo[0].name, - amount: netAmount, - }); - } - } - - // return response - return c.json( - { - transactions: aggregatedTransactions, - } satisfies InfoAboutUserTransactionsResponseSchemaType, - 200 - ); + // レスポンス + return c.json(response, 200); }); -//TODO: ユーザの返済処理を行う -const infoUserRepaymentSchema = route.createSchema( +// NOTE: ユーザの返済処理を行う +const deleteInfoUserRepaymentSchema = route.createSchema( { path: '/api/info/transaction', method: 'delete', @@ -281,31 +104,15 @@ const infoUserRepaymentSchema = route.createSchema( }, }, }, - [401, 500] as const + [400, 401, 500] as const ); -hono.openapi(infoUserRepaymentSchema, async (c) => { +hono.openapi(deleteInfoUserRepaymentSchema, async (c) => { const loginUser = c.get('user'); const body = c.req.valid('json'); - // データベース接続 - const db = drizzle({ connection: c.env.HYPERDRIVE }); - - // 現在時刻の取得 (UTC) - const now = new Date(); - // loginUser <-> counterparty_id 間の取引履歴を削除(deletedAt, deletedBy をセット) - await db - .update(debt) - .set({ - deletedBy: loginUser.id, - deletedAt: now, - }) - .where( - or( - and(eq(debt.creditorId, loginUser.id), eq(debt.debtorId, body.counterparty_id), isNull(debt.deletedAt)), - and(eq(debt.debtorId, loginUser.id), eq(debt.creditorId, body.counterparty_id), isNull(debt.deletedAt)) - ) - ); + // ビジネスロジック呼び出し + await deleteInfoUserRepaymentUseCase(c.env, loginUser.id, body.counterparty_id); // レスポンス return c.body(null, 204); diff --git a/products/backend/src/presentation/routes/session.ts b/products/backend/src/presentation/routes/session.ts new file mode 100644 index 0000000..0d8f38a --- /dev/null +++ b/products/backend/src/presentation/routes/session.ts @@ -0,0 +1,44 @@ +// hono instance +import honoFactory from '../factory/hono'; +// validator +import { sessionCheckResponseSchema } from 'validator'; +// error schema +import { route } from '../share/error'; +// application +import { getSessionCheckUseCase } from '../../application/session'; + +const hono = honoFactory(); + +// NOTE: セッションが有効か確認するエンドポイントのスキーマ定義 +const getSessionCheckSchema = route.createSchema( + { + path: '/api/session', + method: 'get', + description: 'セッションが有効か確認するエンドポイント', + security: [{ SessionCookie: [] }], + request: {}, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: sessionCheckResponseSchema, + }, + }, + }, + }, + }, + [401, 500] as const +); + +hono.openapi(getSessionCheckSchema, async (c) => { + const loginUser = c.get('user'); + + // ビジネスロジック呼び出し + const response = await getSessionCheckUseCase(c.env, loginUser.id); + + // レスポンス + return c.json(response, 200); +}); + +export default hono; diff --git a/products/backend/src/presentation/routes/userProfile.ts b/products/backend/src/presentation/routes/userProfile.ts index dd0c6b8..28e4a92 100644 --- a/products/backend/src/presentation/routes/userProfile.ts +++ b/products/backend/src/presentation/routes/userProfile.ts @@ -1,22 +1,16 @@ // hono instance import honoFactory from '../factory/hono'; // validator -import { - getUserProfileResponseSchema, - type GetUserProfileResponseSchemaType, - updateUserProfileRequestSchema, -} from 'validator'; +import { getUserProfileResponseSchema, updateUserProfileRequestSchema } from 'validator'; // error schema import { route } from '../share/error'; -// drizzle -import { drizzle } from 'drizzle-orm/node-postgres'; -import { eq } from 'drizzle-orm'; -import { user } from '../../db/auth-schema'; +// application layer use cases +import { getUserProfileUseCase, updateUserProfileUseCase } from '../../application/userProfile'; const hono = honoFactory(); -//TODO: userProfileの取得 -const getUserProfile = route.createSchema( +// NOTE: userProfileの取得 +const getUserProfileSchema = route.createSchema( { path: '/api/profile', method: 'get', @@ -34,33 +28,21 @@ const getUserProfile = route.createSchema( }, }, }, - [401, 500] as const + [401, 404, 500] as const ); -hono.openapi(getUserProfile, async (c) => { +hono.openapi(getUserProfileSchema, async (c) => { const loginUser = c.get('user'); - // データベース接続 - const db = drizzle({ connection: c.env.HYPERDRIVE }); - - //* ユーザ情報を取得 (user table) *// - const userData = await db - .select({ display_name: user.displayName }) - .from(user) - .where(eq(user.id, loginUser.id)) - .limit(1); + // ビジネスロジック呼び出し + const response = await getUserProfileUseCase(c.env, loginUser.id); // レスポンス - return c.json( - { - display_name: userData[0].display_name === null ? '' : userData[0].display_name, - } satisfies GetUserProfileResponseSchemaType, - 200 - ); + return c.json(response, 200); }); -//TODO: userProfileの更新 -const updateUserProfile = route.createSchema( +// NOTE: userProfileの更新 +const updateUserProfileSchema = route.createSchema( { path: '/api/profile', method: 'patch', @@ -82,23 +64,15 @@ const updateUserProfile = route.createSchema( }, }, }, - [401, 500] as const + [400, 401, 500] as const ); -hono.openapi(updateUserProfile, async (c) => { +hono.openapi(updateUserProfileSchema, async (c) => { const loginUser = c.get('user'); const body = c.req.valid('json'); - // データベース接続 - const db = drizzle({ connection: c.env.HYPERDRIVE }); - - //* ユーザ情報を更新 (user table) *// - await db - .update(user) - .set({ - displayName: typeof body.display_name === 'undefined' ? null : body.display_name, - }) - .where(eq(user.id, loginUser.id)); + // ビジネスロジック呼び出し + await updateUserProfileUseCase(c.env, loginUser.id, body.display_name); // レスポンス return c.body(null, 204); diff --git a/products/backend/src/presentation/share/error.ts b/products/backend/src/presentation/share/error.ts index 68e393c..bf67605 100644 --- a/products/backend/src/presentation/share/error.ts +++ b/products/backend/src/presentation/share/error.ts @@ -10,6 +10,10 @@ export const route = new ZodOpenAPISchema({ description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } }, }, + 404: { + description: 'Not Found', + content: { 'application/json': { schema: errorResponseSchema } }, + }, 500: { description: 'Internal Server Error', content: { 'application/json': { schema: errorResponseSchema } }, diff --git a/products/backend/src/presentation/utils/types.ts b/products/backend/src/presentation/utils/types.ts deleted file mode 100644 index b853b82..0000000 --- a/products/backend/src/presentation/utils/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type TransactionType = { - user_id: string; // 取引相手のユーザID - lent_amount: number; // 貸した金額 - borrowed_amount: number; // 借りた金額 -}; diff --git a/products/validator/src/index.ts b/products/validator/src/index.ts index 5ffeb18..c86acf5 100644 --- a/products/validator/src/index.ts +++ b/products/validator/src/index.ts @@ -8,24 +8,24 @@ export { } from './request/info'; // info response schema export { - infoAboutGroupsTheUserBelongsToResponseMemberElementSchema, - type InfoAboutGroupsTheUserBelongsToResponseMemberElementSchemaType, + getInfoAboutGroupsTheUserBelongsToResponseMemberElementSchema, + type GetInfoAboutGroupsTheUserBelongsToResponseMemberElementSchemaType, } from './response/info'; export { - infoAboutGroupsTheUserBelongsToResponseGroupElementSchema, - type InfoAboutGroupsTheUserBelongsToResponseGroupElementSchemaType, + getInfoAboutGroupsTheUserBelongsToResponseGroupElementSchema, + type GetInfoAboutGroupsTheUserBelongsToResponseGroupElementSchemaType, } from './response/info'; export { - infoAboutGroupsTheUserBelongsToResponseSchema, - type InfoAboutGroupsTheUserBelongsToResponseSchemaType, + getInfoAboutGroupsTheUserBelongsToResponseSchema, + type GetInfoAboutGroupsTheUserBelongsToResponseSchemaType, } from './response/info'; export { - infoAboutUserTransactionsResponseTransactionElementSchema, - type InfoAboutUserTransactionsResponseTransactionElementSchemaType, + getInfoAboutUserTransactionsResponseTransactionElementSchema, + type GetInfoAboutUserTransactionsResponseTransactionElementSchemaType, } from './response/info'; export { - infoAboutUserTransactionsResponseSchema, - type InfoAboutUserTransactionsResponseSchemaType, + getInfoAboutUserTransactionsResponseSchema, + type GetInfoAboutUserTransactionsResponseSchemaType, } from './response/info'; // userProfile request schema diff --git a/products/validator/src/response/info.ts b/products/validator/src/response/info.ts index 2dc9077..8408562 100644 --- a/products/validator/src/response/info.ts +++ b/products/validator/src/response/info.ts @@ -1,46 +1,46 @@ import { z } from '@hono/zod-openapi'; -export const infoAboutGroupsTheUserBelongsToResponseMemberElementSchema = z.object({ +export const getInfoAboutGroupsTheUserBelongsToResponseMemberElementSchema = z.object({ user_id: z.string().min(1), user_name: z.string().min(1), }); -export const infoAboutGroupsTheUserBelongsToResponseGroupElementSchema = z.object({ +export const getInfoAboutGroupsTheUserBelongsToResponseGroupElementSchema = z.object({ group_id: z.uuid(), group_name: z.string().min(1), created_by_id: z.string().min(1), created_by_name: z.string().min(1), - members: z.array(infoAboutGroupsTheUserBelongsToResponseMemberElementSchema), + members: z.array(getInfoAboutGroupsTheUserBelongsToResponseMemberElementSchema), }); -export const infoAboutGroupsTheUserBelongsToResponseSchema = z.object({ - groups: z.array(infoAboutGroupsTheUserBelongsToResponseGroupElementSchema), +export const getInfoAboutGroupsTheUserBelongsToResponseSchema = z.object({ + groups: z.array(getInfoAboutGroupsTheUserBelongsToResponseGroupElementSchema), }); -export type InfoAboutGroupsTheUserBelongsToResponseMemberElementSchemaType = z.infer< - typeof infoAboutGroupsTheUserBelongsToResponseMemberElementSchema +export type GetInfoAboutGroupsTheUserBelongsToResponseMemberElementSchemaType = z.infer< + typeof getInfoAboutGroupsTheUserBelongsToResponseMemberElementSchema >; -export type InfoAboutGroupsTheUserBelongsToResponseGroupElementSchemaType = z.infer< - typeof infoAboutGroupsTheUserBelongsToResponseGroupElementSchema +export type GetInfoAboutGroupsTheUserBelongsToResponseGroupElementSchemaType = z.infer< + typeof getInfoAboutGroupsTheUserBelongsToResponseGroupElementSchema >; -export type InfoAboutGroupsTheUserBelongsToResponseSchemaType = z.infer< - typeof infoAboutGroupsTheUserBelongsToResponseSchema +export type GetInfoAboutGroupsTheUserBelongsToResponseSchemaType = z.infer< + typeof getInfoAboutGroupsTheUserBelongsToResponseSchema >; -export const infoAboutUserTransactionsResponseTransactionElementSchema = z.object({ +export const getInfoAboutUserTransactionsResponseTransactionElementSchema = z.object({ counterparty_id: z.string().min(1), counterparty_name: z.string().min(1), - amount: z.number().min(0), + amount: z.number(), }); -export const infoAboutUserTransactionsResponseSchema = z.object({ - transactions: z.array(infoAboutUserTransactionsResponseTransactionElementSchema), +export const getInfoAboutUserTransactionsResponseSchema = z.object({ + transactions: z.array(getInfoAboutUserTransactionsResponseTransactionElementSchema), }); -export type InfoAboutUserTransactionsResponseTransactionElementSchemaType = z.infer< - typeof infoAboutUserTransactionsResponseTransactionElementSchema +export type GetInfoAboutUserTransactionsResponseTransactionElementSchemaType = z.infer< + typeof getInfoAboutUserTransactionsResponseTransactionElementSchema >; -export type InfoAboutUserTransactionsResponseSchemaType = z.infer; +export type GetInfoAboutUserTransactionsResponseSchemaType = z.infer;