From 6aa216de1ca0d255b72e88ad9c2686bf757c64f5 Mon Sep 17 00:00:00 2001 From: rito528 <39003544+rito528@users.noreply.github.com> Date: Sat, 9 Nov 2024 00:28:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=83=9A=E3=83=BC=E3=82=B8=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/InputMessageField.tsx | 74 ++++++ .../messages/_components/Messages.tsx | 211 ++++++++++++++++++ .../admin/answer/[answerId]/messages/page.tsx | 68 ++++++ src/app/api/_schemas/ResponseSchemas.ts | 16 ++ .../[answerId]/messages/[messageId]/route.ts | 77 +++++++ .../api/answers/[answerId]/messages/route.ts | 70 ++++++ 6 files changed, 516 insertions(+) create mode 100644 src/app/(authed)/admin/answer/[answerId]/messages/_components/InputMessageField.tsx create mode 100644 src/app/(authed)/admin/answer/[answerId]/messages/_components/Messages.tsx create mode 100644 src/app/(authed)/admin/answer/[answerId]/messages/page.tsx create mode 100644 src/app/api/answers/[answerId]/messages/[messageId]/route.ts create mode 100644 src/app/api/answers/[answerId]/messages/route.ts diff --git a/src/app/(authed)/admin/answer/[answerId]/messages/_components/InputMessageField.tsx b/src/app/(authed)/admin/answer/[answerId]/messages/_components/InputMessageField.tsx new file mode 100644 index 00000000..4fa674fb --- /dev/null +++ b/src/app/(authed)/admin/answer/[answerId]/messages/_components/InputMessageField.tsx @@ -0,0 +1,74 @@ +import SendIcon from '@mui/icons-material/Send'; +import { Button, Container, Grid, TextField, Typography } from '@mui/material'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; + +type SendMessageSchema = { + body: string; +}; + +const InputMessageField = (props: { answer_id: number }) => { + const { handleSubmit, register, reset } = useForm(); + const [sendFailedMessage, setSendFailedMessage] = useState< + string | undefined + >(undefined); + + const onSubmit = async (data: SendMessageSchema) => { + if (data.body === '') { + return; + } + + const response = await fetch(`/api/answers/${props.answer_id}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (response.ok) { + reset({ body: '' }); + setSendFailedMessage(undefined); + } else { + setSendFailedMessage('送信に失敗しました'); + } + }; + + return ( + + + + { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + + await handleSubmit(onSubmit)(); + } + }} + multiline + required + /> + + + + + {sendFailedMessage && ( + + + {sendFailedMessage} + + + )} + + + ); +}; + +export default InputMessageField; diff --git a/src/app/(authed)/admin/answer/[answerId]/messages/_components/Messages.tsx b/src/app/(authed)/admin/answer/[answerId]/messages/_components/Messages.tsx new file mode 100644 index 00000000..bd68efa2 --- /dev/null +++ b/src/app/(authed)/admin/answer/[answerId]/messages/_components/Messages.tsx @@ -0,0 +1,211 @@ +import { MoreVert } from '@mui/icons-material'; +import { + Avatar, + Box, + Chip, + Divider, + Grid, + IconButton, + Menu, + MenuItem, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { left, right } from 'fp-ts/lib/Either'; +import { useState } from 'react'; +import { errorResponseSchema } from '@/app/api/_schemas/ResponseSchemas'; +import { formatString } from '@/generic/DateFormatter'; +import type { ErrorResponse } from '@/app/api/_schemas/ResponseSchemas'; +import type { Either } from 'fp-ts/lib/Either'; + +type Message = { + id: string; + body: string; + sender: { + uuid: string; + name: string; + role: 'ADMINISTRATOR' | 'STANDARD_USER'; + }; + timestamp: string; +}; + +const Message = (props: { + message: Message; + answerId: number; + edittingMessageId: string | undefined; + handleEdit: () => void; + handleCancelEditting: () => void; +}) => { + const [anchorEl, setAnchorEl] = useState(undefined); + const [edittingMessage, setEdittingMessage] = useState(); + const [operationResultMessage, setOperationResultMessage] = useState< + string | undefined + >(undefined); + + const updateMessage = async ( + body: string + ): Promise> => { + const response = await fetch( + `/api/answers/${props.answerId}/messages/${props.message.id}`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ body }), + } + ); + + if (response.ok) { + return right(true); + } else { + const parseResult = errorResponseSchema.safeParse(await response.json()); + + if (parseResult.success) { + return left(parseResult.data); + } else { + return right(false); + } + } + }; + + const deleteMessage = async () => { + await fetch(`/api/answers/${props.answerId}/messages/${props.message.id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + }); + }; + + return ( + + + + + + + + {props.message.sender.role === 'ADMINISTRATOR' ? ( + } + label="運営チーム" + color="success" + /> + ) : null} + {props.message.sender.name} + + {formatString(props.message.timestamp)} + + + + ) => + setAnchorEl(event.currentTarget) + } + > + + + + setAnchorEl(undefined)} + > + props.handleEdit()}>編集 + 削除 + + + + {props.edittingMessageId === props.message.id ? ( + setEdittingMessage(event.target.value)} + onKeyDown={async (event) => { + if ( + event.key === 'Enter' && + edittingMessage !== undefined && + edittingMessage !== '' + ) { + const updateResult = await updateMessage(edittingMessage); + + if ( + updateResult._tag === 'Left' && + updateResult.left.errorCode === 'FORBIDDEN' + ) { + setOperationResultMessage( + 'このメッセージを編集する権限がありません。' + ); + } else if ( + updateResult._tag === 'Right' && + updateResult.right + ) { + props.handleCancelEditting(); + } else { + setOperationResultMessage( + '不明なエラーが発生しました。もう一度お試しください。' + ); + } + } else if (event.key === 'Escape') { + setOperationResultMessage(undefined); + props.handleCancelEditting(); + } + }} + /> + ) : ( + + {props.message.body} + + )} + + {operationResultMessage === undefined ? null : ( + + + + + {operationResultMessage} + + + + )} + + ); +}; + +const Messages = (props: { messages: Message[]; answerId: number }) => { + const [edittingMessageId, setEdittingMessageId] = useState< + string | undefined + >(undefined); + + return ( + + {props.messages.map((message) => ( + + setEdittingMessageId(message.id)} + handleCancelEditting={() => setEdittingMessageId(undefined)} + /> + + + ))} + + ); +}; + +export default Messages; diff --git a/src/app/(authed)/admin/answer/[answerId]/messages/page.tsx b/src/app/(authed)/admin/answer/[answerId]/messages/page.tsx new file mode 100644 index 00000000..1a33e848 --- /dev/null +++ b/src/app/(authed)/admin/answer/[answerId]/messages/page.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { Container, CssBaseline, Stack, ThemeProvider } from '@mui/material'; +import useSWR from 'swr'; +import ErrorModal from '@/app/_components/ErrorModal'; +import LoadingCircular from '@/app/_components/LoadingCircular'; +import InputMessageField from './_components/InputMessageField'; +import Messages from './_components/Messages'; +import adminDashboardTheme from '../../../theme/adminDashboardTheme'; +import type { + ErrorResponse, + GetMessagesResponse, +} from '@/app/api/_schemas/ResponseSchemas'; +import type { Either } from 'fp-ts/lib/Either'; + +const Home = ({ params }: { params: { answerId: number } }) => { + const { data: messages, isLoading: isMessagesLoading } = useSWR< + Either + >(`/api/answers/${params.answerId}/messages`, { refreshInterval: 1000 }); + + if (!messages) { + return ; + } else if ((!isMessagesLoading && !messages) || messages._tag === 'Left') { + return ; + } + + return ( + + + + { + if (el) { + el.scrollTop = el.scrollHeight; + } + }} + sx={{ + flexGrow: 1, + px: { xs: 2, sm: 3 }, + }} + > + + + + + + ); +}; + +export default Home; diff --git a/src/app/api/_schemas/ResponseSchemas.ts b/src/app/api/_schemas/ResponseSchemas.ts index 6d8ed6e4..057c83af 100644 --- a/src/app/api/_schemas/ResponseSchemas.ts +++ b/src/app/api/_schemas/ResponseSchemas.ts @@ -240,6 +240,22 @@ export const getAnswerResponseSchema = z.object({ export type GetAnswerResponse = z.infer; +// GET /forms/answers/:answerId/messages +export const getMessagesResponseSchema = z + .object({ + id: z.string(), + body: z.string().uuid(), + sender: z.object({ + uuid: z.string(), + name: z.string(), + role: z.enum(['ADMINISTRATOR', 'STANDARD_USER']), + }), + timestamp: z.string().datetime(), + }) + .array(); + +export type GetMessagesResponse = z.infer; + // GET /users export const getUsersResponseSchema = z.object({ uuid: z.string(), diff --git a/src/app/api/answers/[answerId]/messages/[messageId]/route.ts b/src/app/api/answers/[answerId]/messages/[messageId]/route.ts new file mode 100644 index 00000000..b427ced6 --- /dev/null +++ b/src/app/api/answers/[answerId]/messages/[messageId]/route.ts @@ -0,0 +1,77 @@ +'use server'; + +import { NextResponse } from 'next/server'; +import { nextResponseFromResponseHeaders } from '@/app/api/_generics/responseHeaders'; +import { BACKEND_SERVER_URL } from '@/env'; +import { getCachedToken } from '@/user-token/mcToken'; +import type { NextRequest } from 'next/server'; + +export async function PATCH( + req: NextRequest, + { params }: { params: { answerId: string; messageId: string } } +) { + const token = await getCachedToken(); + if (!token) { + return NextResponse.redirect('/'); + } + + const response = await fetch( + `${BACKEND_SERVER_URL}/forms/answers/${params.answerId}/messages/${params.messageId}`, + { + method: 'PATCH', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(await req.json()), + cache: 'no-cache', + } + ); + + if (response.ok) { + return nextResponseFromResponseHeaders( + NextResponse.json({ status: response.status }), + response + ); + } else { + return nextResponseFromResponseHeaders( + NextResponse.json(await response.json(), { status: response.status }), + response + ); + } +} + +export async function DELETE( + _: NextRequest, + { params }: { params: { answerId: number; messageId: string } } +) { + const token = await getCachedToken(); + if (!token) { + return NextResponse.redirect('/'); + } + + const response = await fetch( + `${BACKEND_SERVER_URL}/forms/answers/${params.answerId}/messages/${params.messageId}`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + cache: 'no-cache', + } + ); + + if (response.ok) { + return nextResponseFromResponseHeaders( + NextResponse.json({ status: response.status }), + response + ); + } else { + return nextResponseFromResponseHeaders( + NextResponse.json(await response.json(), { status: response.status }), + response + ); + } +} diff --git a/src/app/api/answers/[answerId]/messages/route.ts b/src/app/api/answers/[answerId]/messages/route.ts new file mode 100644 index 00000000..2584e279 --- /dev/null +++ b/src/app/api/answers/[answerId]/messages/route.ts @@ -0,0 +1,70 @@ +'use server'; + +import { NextResponse } from 'next/server'; +import { nextResponseFromResponseHeaders } from '@/app/api/_generics/responseHeaders'; +import { BACKEND_SERVER_URL } from '@/env'; +import { getCachedToken } from '@/user-token/mcToken'; +import type { NextRequest } from 'next/server'; + +export async function GET( + _: NextRequest, + { params }: { params: { answerId: string } } +) { + const token = await getCachedToken(); + if (!token) { + return NextResponse.redirect('/'); + } + + const response = await fetch( + `${BACKEND_SERVER_URL}/forms/answers/${params.answerId}/messages`, + { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + }, + cache: 'no-cache', + } + ); + + return nextResponseFromResponseHeaders( + NextResponse.json(await response.json(), { status: response.status }), + response + ); +} + +export async function POST( + req: NextRequest, + { params }: { params: { answerId: string } } +) { + const token = await getCachedToken(); + if (!token) { + return NextResponse.redirect('/'); + } + + const response = await fetch( + `${BACKEND_SERVER_URL}/forms/answers/${params.answerId}/messages`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(await req.json()), + cache: 'no-cache', + } + ); + + if (response.ok) { + return nextResponseFromResponseHeaders( + NextResponse.json({ status: response.status }), + response + ); + } else { + return nextResponseFromResponseHeaders( + NextResponse.json(await response.json(), { status: response.status }), + response + ); + } +}