diff --git a/common/types.ts b/common/types.ts index dc705e6c..51cb39e5 100644 --- a/common/types.ts +++ b/common/types.ts @@ -14,6 +14,7 @@ export type { RelationshipID, Relationship, CourseID, + InterestSubjectID, Slot, Course, Enrollment, diff --git a/common/zod/schemas.ts b/common/zod/schemas.ts index 432ab50d..7a25805c 100644 --- a/common/zod/schemas.ts +++ b/common/zod/schemas.ts @@ -32,17 +32,6 @@ export const IntroLongSchema = z // .min(2, { message: "自己紹介文は2文字以上です" }) .max(225, { message: "自己紹介文は225文字以下です" }); -export const InterestSubjectSchema = z.object({ - id: z.number(), - name: z.string(), - group: z.string(), -}); - -export const InterestSchema = z.object({ - userId: UserIDSchema, - subjectId: z.number(), -}); - export const UserSchema = z.object({ id: UserIDSchema, guid: GUIDSchema, @@ -76,6 +65,8 @@ export const RelationshipSchema = z.object({ export const CourseIDSchema = z.string(); +export const InterestSubjectIDSchema = z.number(); + export const DaySchema = z.enum([ "mon", "tue", @@ -108,6 +99,17 @@ export const EnrollmentSchema = z.object({ courseId: CourseIDSchema, }); +export const InterestSubjectSchema = z.object({ + id: InterestSubjectIDSchema, + name: z.string(), + group: z.string(), +}); + +export const InterestSchema = z.object({ + userId: UserIDSchema, + subjectId: InterestSubjectIDSchema, +}); + export const UserWithCoursesAndSubjectsSchema = UserSchema.extend({ courses: CourseSchema.array(), interestSubjects: InterestSubjectSchema.array(), diff --git a/common/zod/types.ts b/common/zod/types.ts index 68714005..cb195e8b 100644 --- a/common/zod/types.ts +++ b/common/zod/types.ts @@ -15,6 +15,7 @@ import type { InitSharedRoomSchema, InitUserSchema, InterestSchema, + InterestSubjectIDSchema, InterestSubjectSchema, IntroLongSchema, IntroShortSchema, @@ -57,6 +58,7 @@ export type Step1User = z.infer; export type RelationshipID = z.infer; export type Relationship = z.infer; export type CourseID = z.infer; +export type InterestSubjectID = z.infer; export type Slot = z.infer; export type Course = z.infer; export type Enrollment = z.infer; diff --git a/server/src/database/interest.ts b/server/src/database/interest.ts index 6b87edea..e1e15b0d 100644 --- a/server/src/database/interest.ts +++ b/server/src/database/interest.ts @@ -9,6 +9,21 @@ export async function get(id: number): Promise { return await prisma.interestSubject.findUnique({ where: { id } }); } +export async function create(name: string): Promise { + const existingTag = await prisma.interestSubject.findMany({ + where: { name }, + }); + if (existingTag.length > 0) { + throw new Error("同名のタグがすでに存在します"); + } + return await prisma.interestSubject.create({ + data: { + name, + group: "", // TODO: 運用次第 + }, + }); +} + export async function of(userId: UserID): Promise { return await prisma.interest .findMany({ @@ -37,3 +52,23 @@ export async function remove(userId: UserID, subjectId: number) { }, }); } + +export async function updateMultipleWithTransaction( + userId: UserID, + subjectIds: number[], +) { + return await prisma.$transaction(async (prisma) => { + await prisma.interest.deleteMany({ + where: { + userId, + }, + }); + + await prisma.interest.createMany({ + data: subjectIds.map((subjectId) => ({ + userId, + subjectId, + })), + }); + }); +} diff --git a/server/src/index.ts b/server/src/index.ts index c7b7878a..0643cf3e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -9,6 +9,7 @@ import coursesRoutes from "./router/courses"; import matchesRoutes from "./router/matches"; import pictureRoutes from "./router/picture"; import requestsRoutes from "./router/requests"; +import subjectsRoutes from "./router/subjects"; import usersRoutes from "./router/users"; const app = express(); @@ -52,6 +53,7 @@ app.get("/", (_, res) => { app.use("/picture", pictureRoutes); app.use("/users", usersRoutes); app.use("/courses", coursesRoutes); +app.use("/subjects", subjectsRoutes); app.use("/requests", requestsRoutes); app.use("/matches", matchesRoutes); app.use("/chat", chatRoutes); diff --git a/server/src/router/subjects.ts b/server/src/router/subjects.ts new file mode 100644 index 00000000..6a7eef0c --- /dev/null +++ b/server/src/router/subjects.ts @@ -0,0 +1,131 @@ +import express, { type Request, type Response } from "express"; +import * as interest from "../database/interest"; +import { safeGetUserId } from "../firebase/auth/db"; + +const router = express.Router(); + +router.get("/userId/:userId", async (req: Request, res: Response) => { + const userId = Number.parseInt(req.params.userId); + if (Number.isNaN(userId)) { + return res.status(400).json({ error: "Invalid userId" }); + } + try { + const subjects = await interest.of(userId); + res.status(200).json(subjects); + } catch (error) { + console.error("Error fetching subjects by userId:", error); + res.status(500).json({ error: "Failed to fetch subjects by userId" }); + } +}); + +router.get("/mine", async (req: Request, res: Response) => { + const userId = await safeGetUserId(req); + if (!userId.ok) return res.status(401).send("auth error"); + try { + const subjects = await interest.of(userId.value); + res.status(200).json(subjects); + } catch (error) { + console.error("Error fetching subjects:", error); + res.status(500).json({ error: "Failed to fetch subjects" }); + } +}); + +router.post("/", async (req: Request, res: Response) => { + const { name } = req.body; + if (typeof name !== "string") { + return res.status(400).json({ error: "name must be a string" }); + } + try { + const newSubject = await interest.create(name); + res.status(201).json(newSubject); + } catch (error) { + console.error("Error creating subject:", error); + res.status(500).json({ error: "Failed to create subject" }); + } +}); + +router.patch("/mine", async (req: Request, res: Response) => { + const userId = await safeGetUserId(req); + if (!userId.ok) return res.status(401).send("auth error"); + const { subjectId } = req.body; + try { + const newSubject = await interest.get(subjectId); + if (!newSubject) { + return res.status(404).json({ error: "Subject not found" }); + } + } catch (err) { + console.error("Error fetching subject:", err); + res.status(500).json({ error: "Failed to fetch subject" }); + } + try { + const updatedSubjects = await interest.add(userId.value, subjectId); + res.status(200).json(updatedSubjects); + } catch (error) { + console.error("Error updating subjects:", error); + res.status(500).json({ error: "Failed to update subjects" }); + } +}); + +router.delete("/mine", async (req: Request, res: Response) => { + const userId = await safeGetUserId(req); + if (!userId.ok) return res.status(401).send("auth error"); + const { subjectId } = req.body; + try { + const newSubject = await interest.get(subjectId); + if (!newSubject) { + return res.status(404).json({ error: "Subject not found" }); + } + } catch (err) { + console.error("Error fetching subject:", err); + res.status(500).json({ error: "Failed to fetch subject" }); + } + try { + const updatedSubjects = await interest.remove(userId.value, subjectId); + res.status(200).json(updatedSubjects); + } catch (error) { + console.error("Error deleting subjects:", error); + res.status(500).json({ error: "Failed to delete subjects" }); + } +}); + +router.put("/mine", async (req: Request, res: Response) => { + const userId = await safeGetUserId(req); + if (!userId.ok) return res.status(401).send("auth error"); + const { subjectIds } = req.body; + if (!Array.isArray(subjectIds)) { + return res.status(400).json({ error: "subjectIds must be an array" }); + } + try { + const newSubjects = await Promise.all( + subjectIds.map((id) => interest.get(id)), + ); + if (newSubjects.some((s) => !s)) { + return res.status(404).json({ error: "Subject not found" }); + } + } catch (err) { + console.error("Error fetching subjects:", err); + res.status(500).json({ error: "Failed to fetch subjects" }); + } + try { + const updatedSubjects = await interest.updateMultipleWithTransaction( + userId.value, + subjectIds, + ); + res.status(200).json(updatedSubjects); + } catch (error) { + console.error("Error updating subjects:", error); + res.status(500).json({ error: "Failed to update subjects" }); + } +}); + +router.get("/all", async (req: Request, res: Response) => { + try { + const subjects = await interest.all(); + res.status(200).json(subjects); + } catch (error) { + console.error("Error fetching subjects:", error); + res.status(500).json({ error: "Failed to fetch subjects" }); + } +}); + +export default router; diff --git a/web/api/internal/endpoints.ts b/web/api/internal/endpoints.ts index d10d611e..30c94764 100644 --- a/web/api/internal/endpoints.ts +++ b/web/api/internal/endpoints.ts @@ -236,6 +236,73 @@ export const coursesDayPeriod = (day: Day, period: number) => { return `${API_ENDPOINT}/courses/day-period?day=${day}&period=${period}`; }; +/** + * [v] 実装済み + * POST -> create a new subject. + * - statuses: + * - 200: ok. + * - body: InterestSubject + * - 401: unauthorized. + * - 500: internal error. + */ +export const subjects = `${API_ENDPOINT}/subjects`; + +/** + * [v] 実装済み + * GET -> get subjects the user is interested in. + * - statuses: + * - 200: ok. + * - body: InterestSubject[] + * - 401: unauthorized. + * - 500: internal error. + */ +export const subjectsUserId = (userId: UserID) => { + return `${API_ENDPOINT}/subjects/userId/${userId}`; +}; + +/** + * [v] 実装済み + * GET -> get my subjects. + * - statuses: + * - 200: ok. + * - body: InterestSubject[] + * - 401: unauthorized. + * - 500: internal error. + * PATCH -> update my subjects. + * - request body: SubjectId + * - statuses: + * - 200: ok. + * - body: InterestSubject[] + * - 401: unauthorized. + * - 500: internal error. + * DELETE -> delete my subjects. + * - request body: SubjectId + * - statuses: + * - 200: ok. + * - body: InterestSubject[] + * - 401: unauthorized. + * - 500: internal error. + * PUT → replace my subjects. + * - request body: SubjectId[] + * - statuses: + * - 200: ok. + * - body: InterestSubject[] + * - 401: unauthorized. + * - 500: internal error. + */ +export const subjectsMine = `${API_ENDPOINT}/subjects/mine`; + +/** + * [v] 実装済み + * GET -> get all subjects. + * - statuses: + * - 200: ok. + * - body: InterestSubject[] + * - 401: unauthorized. + * - 500: internal error. + */ +export const subjectsAll = `${API_ENDPOINT}/subjects/all`; + /** * [v] 実装済み * PUT -> create request. @@ -400,6 +467,10 @@ export default { cancelRequest, coursesUserId, coursesDayPeriod, + subjects, + subjectsUserId, + subjectsMine, + subjectsAll, roomOverview, dmTo, dmWith, diff --git a/web/api/subject.ts b/web/api/subject.ts new file mode 100644 index 00000000..8b49764f --- /dev/null +++ b/web/api/subject.ts @@ -0,0 +1,49 @@ +import type { + InterestSubject, + InterestSubjectID, + UserID, +} from "common/types.ts"; +import { InterestSubjectSchema } from "common/zod/schemas.ts"; +import { z } from "zod"; +import { credFetch } from "../firebase/auth/lib.ts"; +import { type Hook, useCustomizedSWR } from "../hooks/useCustomizedSWR.ts"; +import endpoints from "./internal/endpoints.ts"; + +const InterestSubjectListSchema = z.array(InterestSubjectSchema); + +// 興味分野を作成する +export async function create(name: string) { + return await credFetch("POST", endpoints.subjects, { name }); +} + +// 自身の興味分野を取得する +export function useMyInterests(): Hook { + return useCustomizedSWR( + "interests::mine", + getMySubjects, + InterestSubjectListSchema, + ); +} + +async function getMySubjects(): Promise { + const res = await credFetch("GET", endpoints.subjectsMine); + return res.json(); +} + +// 自身の興味分野を更新する +export async function update(newSubjectIds: InterestSubjectID[]) { + const url = endpoints.subjectsMine; + return await credFetch("PUT", url, { subjectIds: newSubjectIds }); +} + +// 指定した userId のユーザの興味分野を取得 +export async function get(id: UserID): Promise { + const res = await credFetch("GET", endpoints.subjectsUserId(id)); + return await res.json(); +} + +// すべての興味分野を取得 +export async function getAll(): Promise { + const res = await credFetch("GET", endpoints.subjectsAll); + return await res.json(); +} diff --git a/web/app/edit/interests/page.tsx b/web/app/edit/interests/page.tsx new file mode 100644 index 00000000..c8b31466 --- /dev/null +++ b/web/app/edit/interests/page.tsx @@ -0,0 +1,234 @@ +"use client"; + +import type { InterestSubject } from "common/types"; +import { useRouter } from "next/navigation"; +import { enqueueSnackbar } from "notistack"; +import { useEffect, useState } from "react"; +import { MdAdd, MdClose } from "react-icons/md"; +import FullScreenCircularProgress from "~/components/common/FullScreenCircularProgress"; +import { useAlert } from "~/components/common/alert/AlertProvider"; +import * as subject from "../../../api/subject"; + +export default function EditInterest() { + const { state } = subject.useMyInterests(); + const data = state.data; + const error = state.current === "error" ? state.error : null; + const loading = state.current === "loading"; + + const router = useRouter(); + const { showAlert } = useAlert(); + + const [allSubjects, setAllSubjects] = useState([]); + const [filteredSubjects, setFilteredSubjects] = useState( + [], + ); + const [draftSubjects, setDraftSubjects] = useState( + data ?? [], + ); + const [isOpen, setIsOpen] = useState(false); + const [newSubjectName, setNewSubjectName] = useState(""); + + useEffect(() => { + getSubjects(); + }, []); + + useEffect(() => { + setDraftSubjects(data ?? []); + }, [data]); + + async function getSubjects() { + const subjects = await subject.getAll(); + setAllSubjects(subjects); + setFilteredSubjects(subjects); + } + + async function updateInterests(data: { + interestSubjects: InterestSubject[]; + }) { + const ids = data.interestSubjects.map((d) => d.id); + const result = await subject.update(ids); + if (!result.ok) { + enqueueSnackbar("興味分野の保存に失敗しました", { variant: "error" }); + } else { + enqueueSnackbar("興味分野を保存しました", { variant: "success" }); + } + } + + async function createSubject(name: string) { + const result = await subject.create(name); + if (!result.ok) { + enqueueSnackbar("興味分野の作成に失敗しました", { variant: "error" }); + } else { + enqueueSnackbar("興味分野を作成しました", { variant: "success" }); + } + } + + function handleBack() { + // TODO: 差分がないときは確認なしで戻る + showAlert({ + AlertMessage: "編集中のフィールド、もしくはエラーがあります。", + subAlertMessage: "本当にページを移動しますか?変更は破棄されます", + yesMessage: "移動", + clickYes: () => { + router.push("/settings/profile"); + }, + }); + } + + return loading ? ( + + ) : error ? ( +

Error: {error.message}

+ ) : !data ? ( +

データがありません。

+ ) : ( + <> +
+
+
+
+ {draftSubjects.map((subject, index) => ( + + #{subject.name} + + + ))} +
+
+ { + const newFilteredSubjects = allSubjects.filter((subject) => + subject.name.includes(e.target.value.trim()), + ); + setFilteredSubjects(newFilteredSubjects); + }} + placeholder="興味分野タグを絞り込み" + className="input input-bordered w-full" + /> +
+
    + {filteredSubjects.length !== 0 ? ( + filteredSubjects + .filter( + (subject) => + !draftSubjects.some((draft) => draft.id === subject.id), + ) + .map((subject) => ( +
  • + +
  • + )) + ) : ( +
  • + 検索結果がありません +
  • + )} +
  • + +
  • +
+
+
+ + +
+
+
+ {isOpen && ( + setIsOpen(false)} + > +
+

興味分野タグの作成

+ setNewSubjectName(e.target.value)} + placeholder="タグ名を入力" + /> + {newSubjectName && ( +

+ 興味分野タグ{" "} + #{newSubjectName}{" "} + を作成します +

+ )} +
+
+
+ + +
+
+
+
+
+ )} + + ); +} diff --git a/web/app/edit/layout.tsx b/web/app/edit/layout.tsx index be75caf1..93035146 100644 --- a/web/app/edit/layout.tsx +++ b/web/app/edit/layout.tsx @@ -10,7 +10,7 @@ export default function EditPageLayout({
-
{children}
+
{children}
); diff --git a/web/app/edit/profile/page.tsx b/web/app/edit/profile/page.tsx index 9cd1a929..59851bd1 100644 --- a/web/app/edit/profile/page.tsx +++ b/web/app/edit/profile/page.tsx @@ -27,9 +27,7 @@ export default function App() { ) : error ? (

Error: {error.message}

) : data ? ( -
- -
+ ) : (

データがありません。

); @@ -99,8 +97,8 @@ function EditProfile({ defaultValues }: { defaultValues: User }) { const values = getValues(); return ( -
-
+
+