diff --git a/Documents/API.md b/Documents/API.md index 4dc986d..96f815f 100644 --- a/Documents/API.md +++ b/Documents/API.md @@ -108,7 +108,7 @@ API is provided by Firebase Cloud Functions. Database is provided by Firestore. "text": "hoge fuga", "post": { "userId": "Vlx6GCtq90ag3lxgh0pcCKGp5ba0", - "storedURL": "hogehoge URL", + "storedId": "0f9a84ed-8ae8-44b0-a6f5-5ac5ca517948", "text": "数学の勉強したよ^^", "submittedAt": { "_seconds": 1735603199, @@ -151,14 +151,14 @@ API is provided by Firebase Cloud Functions. Database is provided by Firestore. - Body (form-data) - goalId: string - text: string - - storedURL: string (画像のストレージパス、/post/{storedURL}/image) + - storedId: string (画像のストレージパス、/post/{storedId}/image) - submittedAt: Date - Example ```json { "goalId": "RXlHJiv3GtpzSDHhfljS", "text": "今日は勉強をがんばった", - "storedURL": "hogehoge URL", + "storedId": "0f9a84ed-8ae8-44b0-a6f5-5ac5ca517948", "submittedAt": "2024-12-31T23:59:59.000Z" } ``` @@ -182,7 +182,7 @@ API is provided by Firebase Cloud Functions. Database is provided by Firestore. "goalId": "9fgWJA6wMN54EkxIC2WD", "userId": "IK0Zc2hoUYaYjXoqzmCl", "text": "今日は勉強をがんばった", - "storedURL": "hogehoge URL", + "storedId": "0f9a84ed-8ae8-44b0-a6f5-5ac5ca517948", "goalId": "RXlHJiv3GtpzSDHhfljS", "submittedAt": "2024-12-31T23:59:59.000Z" } @@ -201,8 +201,10 @@ Use Create Post API to update post. - URL: /result/:?userId - Empty userId will return all results. - Parameters - - limit?: number - The maximum number of results to return.(Default is 50) + - limit?: number - The maximum number of results to return. (Default is 50) - offset?: number - The number of results to skip before starting to collect the result set. + - onlyPending?: boolean - If true, only pending goals will be returned. (Default is false) + - onlyCompleted?: boolean - If true, only completed or failed goals will be returned. (Default is false) - Method: GET - Response ```json @@ -215,7 +217,7 @@ Use Create Post API to update post. "text": "Duolingoやる", "post": { "text": "フランス語したよ", - "storedURL": "hogehoge URL", + "storedId": "0f9a84ed-8ae8-44b0-a6f5-5ac5ca517948", "submittedAt": "2024-12-28T09:45:10.718Z" }, "userData": { diff --git a/functions/src/index.ts b/functions/src/index.ts index e892a40..cfb43c0 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -9,7 +9,7 @@ import serviceAccount from "./serviceAccountKey.json"; admin.initializeApp({ credential: admin.credential.cert(serviceAccount as admin.ServiceAccount), - storageBucket: "todo-real-c28fa.appspot.com", + storageBucket: "todo-real-c28fa.firebasestorage.app", }); import goalRouter from "./routers/goalRouter"; @@ -61,26 +61,36 @@ if (process.env.NODE_ENV === "production") { }); } -// 10分間で最大300回に制限 +// 10分間で最大100回に制限 app.use( rateLimit({ windowMs: 10 * 60 * 1000, - max: 300, + max: 100, keyGenerator: (req) => { const key = req.headers["x-forwarded-for"] || req.ip || "unknown"; return Array.isArray(key) ? key[0] : key; }, + handler: (req, res) => { + return res + .status(429) + .json({ message: "Too many requests, please try again later." }); + }, }) ); -// 1時間で最大1000回に制限 +// 1時間で最大300回に制限 app.use( rateLimit({ windowMs: 60 * 60 * 1000, - max: 1000, + max: 300, keyGenerator: (req) => { const key = req.headers["x-forwarded-for"] || req.ip || "unknown"; return Array.isArray(key) ? key[0] : key; }, + handler: (req, res) => { + return res + .status(429) + .json({ message: "Too many requests, please try again later." }); + }, }) ); diff --git a/functions/src/routers/goalRouter.ts b/functions/src/routers/goalRouter.ts index 60ac04e..051a53f 100644 --- a/functions/src/routers/goalRouter.ts +++ b/functions/src/routers/goalRouter.ts @@ -1,7 +1,7 @@ import express, { Request, Response } from "express"; import admin from "firebase-admin"; import { logger } from "firebase-functions"; -import { Goal, GoalWithId } from "./types"; +import { Goal, GoalWithId } from "../types"; const router = express.Router(); const db = admin.firestore(); @@ -147,14 +147,35 @@ router.put("/:goalId", async (req: Request, res: Response) => { // DELETE: 目標を削除 router.delete("/:goalId", async (req: Request, res: Response) => { - const goalId = req.params.goalId; + try { + const goalId = req.params.goalId; - if (!goalId) { - return res.status(400).json({ message: "Goal ID is required" }); - } + if (!goalId) { + return res.status(400).json({ message: "Goal ID is required" }); + } - try { - await db.collection("goal").doc(goalId).delete(); + const goalRef = db.collection("goal").doc(goalId); + const goalDoc = await goalRef.get(); + + if (!goalDoc.exists) { + return res.status(404).json({ message: "Goal not found" }); + } + + // storageから画像を削除 + const storedId = goalDoc.data()?.post?.storedId; + if (storedId) { + try { + const bucket = admin.storage().bucket(); + const file = bucket.file(`post/${storedId}`); + await file.delete(); + logger.info("Image deleted successfully:", storedId); + } catch (error) { + logger.error("Error deleting image:", error); + return res.status(500).json({ message: "Error deleting image" }); + } + } + + await goalRef.delete(); return res.json({ message: "Goal deleted successfully", goalId }); } catch (error) { logger.error(error); diff --git a/functions/src/routers/postRouter.ts b/functions/src/routers/postRouter.ts index 45ab8a2..d17c11f 100644 --- a/functions/src/routers/postRouter.ts +++ b/functions/src/routers/postRouter.ts @@ -1,8 +1,8 @@ import express, { Request, Response } from "express"; import admin from "firebase-admin"; import { logger } from "firebase-functions"; -import { updateStreak } from "./status"; -import { PostWithGoalId } from "./types"; +import { updateStreak } from "../status"; +import { PostWithGoalId } from "../types"; const router = express.Router(); const db = admin.firestore(); @@ -25,7 +25,7 @@ router.get("/", async (req: Request, res: Response) => { goalId: goalDoc.id, userId: goalData.userId, text: goalData.post.text, - storedURL: goalData.post.storedURL, + storedId: goalData.post.storedId, submittedAt: goalData.post.submittedAt.toDate(), }); } @@ -69,7 +69,7 @@ router.get("/:userId", async (req: Request, res: Response) => { goalId: goalDoc.id, userId: goalData.userId, text: goalData.post.text, - storedURL: goalData.post.storedURL, + storedId: goalData.post.storedId, submittedAt: goalData.post.submittedAt.toDate(), }); } @@ -86,19 +86,19 @@ router.get("/:userId", async (req: Request, res: Response) => { router.post("/", async (req: Request, res: Response) => { let goalId: PostWithGoalId["goalId"]; let text: PostWithGoalId["text"]; - let storedURL: PostWithGoalId["storedURL"]; + let storedId: PostWithGoalId["storedId"]; let submittedAt: PostWithGoalId["submittedAt"]; try { - ({ goalId, text = "", storedURL, submittedAt } = req.body); + ({ goalId, text = "", storedId, submittedAt } = req.body); } catch (error) { logger.error(error); return res.status(400).json({ message: "Invalid request body" }); } - if (!goalId || !storedURL || !submittedAt) { + if (!goalId || !storedId || !submittedAt) { return res.status(400).json({ - message: "userId, storedURL, goalId, and submittedAt are required", + message: "userId, storedId, goalId, and submittedAt are required", }); } @@ -119,7 +119,7 @@ router.post("/", async (req: Request, res: Response) => { await goalRef.update({ post: { text, - storedURL, + storedId, submittedAt: new Date(submittedAt), }, }); @@ -135,9 +135,13 @@ router.post("/", async (req: Request, res: Response) => { // DELETE: 投稿を削除 router.delete("/:goalId", async (req: Request, res: Response) => { - const goalId = req.params.goalId; - try { + const goalId = req.params.goalId; + + if (!goalId) { + return res.status(400).json({ message: "Goal ID is required" }); + } + const goalRef = db.collection("goal").doc(goalId); const goalDoc = await goalRef.get(); @@ -145,8 +149,22 @@ router.delete("/:goalId", async (req: Request, res: Response) => { return res.status(404).json({ message: "Goal not found" }); } + // Storageから画像を削除 + const storedId = goalDoc.data()?.post?.storedId; + if (storedId) { + try { + const bucket = admin.storage().bucket(); + const file = bucket.file(`post/${storedId}`); + await file.delete(); + logger.info("Image deleted successfully:", storedId); + } catch (error) { + logger.error("Error deleting image:", error); + return res.status(500).json({ message: "Error deleting image" }); + } + } + await goalRef.update({ - post: admin.firestore.FieldValue.delete(), + post: null, }); return res.json({ message: "Post deleted successfully" }); diff --git a/functions/src/routers/resultRouter.ts b/functions/src/routers/resultRouter.ts index f5b1300..e7c6be4 100644 --- a/functions/src/routers/resultRouter.ts +++ b/functions/src/routers/resultRouter.ts @@ -1,74 +1,91 @@ import express, { Request, Response } from "express"; import admin from "firebase-admin"; import { logger } from "firebase-functions"; -import { countCompletedGoals, countFailedGoals, getStreak } from "./status"; -import { GoalWithIdAndUserData, User } from "./types"; +import { countCompletedGoals, countFailedGoals, getStreak } from "../status"; +import { GoalWithIdAndUserData, User } from "../types"; +import { getUserFromId } from "./userRouter"; const router = express.Router(); const db = admin.firestore(); const getResults = async ( + res: Response, limit: number, offset: number, userId?: string, - includeSuccess = true, - includeFailed = true, - includePending = true + onlyPending = false, + onlyFinished = false ) => { - let goalQuery = db.collection("goal").limit(limit).offset(offset); + let baseQuery = db.collection("goal").limit(limit).offset(offset); + if (userId) { - goalQuery = goalQuery.where("userId", "==", userId); + const userDoc = await getUserFromId(userId); + if (!userDoc.exists) { + return res.status(404).json({ message: "User not found" }); + } + baseQuery = baseQuery.where("userId", "==", userId); } - if (!includeSuccess) { - goalQuery = goalQuery.where("post", "==", null); + if (onlyPending && onlyFinished) { + return res.status(400).json({ + message: + "Cannot set both 'onlyPending' and 'onlyFinished'. Please set only one of 'onlyPending' or 'onlyFinished', or leave both false.", + }); } - if (!includeFailed) { - goalQuery = goalQuery - .where("post", "!=", null) - .where("deadline", ">", new Date()); - } + const now = admin.firestore.Timestamp.now(); + + const pendingResults: GoalWithIdAndUserData[] = []; + const successResults: GoalWithIdAndUserData[] = []; + const failedResults: GoalWithIdAndUserData[] = []; - if (!includePending) { - goalQuery = goalQuery + const userList = new Map(); // ユーザー情報のキャッシュ + + if (onlyPending || (!onlyPending && !onlyFinished)) { + const pendingSnapshot = await baseQuery .where("post", "==", null) - .where("deadline", "<=", new Date()); + .where("deadline", ">", now) + .get(); + + const pendingGoals = await processGoals(pendingSnapshot.docs, userList); + pendingResults.push(...pendingGoals); } - const goalSnapshot = await goalQuery.get(); + if (onlyFinished || (!onlyPending && !onlyFinished)) { + const completedSnapshot = await baseQuery.where("post", "!=", null).get(); - const goals = goalSnapshot.docs.map((doc) => { - const data = doc.data(); - const post = data.post; + const failedSnapshot = await baseQuery + .where("post", "==", null) + .where("deadline", "<=", now) + .get(); - return { - goalId: doc.id, - userId: data.userId, - deadline: data.deadline.toDate(), - text: data.text, - post: post && { - text: post.text, - storedURL: post.storedURL, - submittedAt: post.submittedAt.toDate(), - }, - }; - }) as GoalWithIdAndUserData[]; + const completedResults = await processGoals( + completedSnapshot.docs, + userList + ); + const failedResultsTemp = await processGoals(failedSnapshot.docs, userList); - if (!goals || goals.length === 0) { - return { successResults: [], failedResults: [], pendingResults: [] }; + successResults.push(...completedResults); + failedResults.push(...failedResultsTemp); } - const successResults: GoalWithIdAndUserData[] = []; - const failedResults: GoalWithIdAndUserData[] = []; - const pendingResults: GoalWithIdAndUserData[] = []; + return { + successResults, + failedResults, + pendingResults, + }; +}; + +const processGoals = async ( + docs: FirebaseFirestore.QueryDocumentSnapshot[], + userList: Map +) => { + const results: GoalWithIdAndUserData[] = []; - // mapでのリストを作成し、userNameをキャッシュする - const userList = new Map(); + for (const doc of docs) { + const data = doc.data(); + const userId = data.userId; - for (const goal of goals) { - // userListにあるならば、userNameを取得し、無いならばfirestoreから取得してキャッシュする - const userId = goal.userId; let userData = userList.get(userId); if (!userData) { const userDoc = await db.collection("user").doc(userId).get(); @@ -86,57 +103,44 @@ const getResults = async ( userList.set(userId, userData); } - const post = goal.post; - if (post) { - if (post.submittedAt > goal.deadline) { - failedResults.push({ - ...goal, - userData, - }); - } else { - successResults.push({ - ...goal, - userData, - }); - } - } else if (goal.deadline < new Date()) { - failedResults.push({ - ...goal, - userData, - }); - } else { - pendingResults.push({ - ...goal, - userData, - }); - } + const post = data.post; + results.push({ + goalId: doc.id, + userId: data.userId, + deadline: data.deadline.toDate(), + text: data.text, + post: post && { + text: post.text, + storedId: post.storedId, + submittedAt: post.submittedAt.toDate(), + }, + userData, + }); } - return { - successResults, - failedResults, - pendingResults, - }; + return results; }; // GET: 全ての目標または特定のユーザーの目標に対する結果を取得 router.get("/:userId?", async (req: Request, res: Response) => { const userId = req.params.userId; - const limit = parseInt(req.query.limit as string) || 100; // TODO: デフォルト値を適切に設定 + let limit = parseInt(req.query.limit as string) || 10; + if (limit < 1 || limit > 100) { + limit = 100; + } const offset = parseInt(req.query.offset as string) || 0; - const includeSuccess = req.query.success !== "false"; - const includeFailed = req.query.failed !== "false"; - const includePending = req.query.pending !== "false"; + const onlyPending = req.query.onlyPending === "true"; // デフォルト: false + const onlyFinished = req.query.onlyFinished === "true"; // デフォルト: false try { const results = await getResults( + res, limit, offset, userId, - includeSuccess, - includeFailed, - includePending + onlyPending, + onlyFinished ); return res.json(results); } catch (error) { diff --git a/functions/src/routers/userRouter.ts b/functions/src/routers/userRouter.ts index 8aaff72..c9de9f0 100644 --- a/functions/src/routers/userRouter.ts +++ b/functions/src/routers/userRouter.ts @@ -1,8 +1,8 @@ import express, { Request, Response } from "express"; import admin from "firebase-admin"; import { logger } from "firebase-functions"; -import { countCompletedGoals, countFailedGoals, getStreak } from "./status"; -import { User } from "./types"; +import { countCompletedGoals, countFailedGoals, getStreak } from "../status"; +import { User } from "../types"; const router = express.Router(); const db = admin.firestore(); @@ -179,14 +179,21 @@ router.put("/:userId", async (req: Request, res: Response) => { // DELETE: ユーザーを削除 router.delete("/:userId", async (req: Request, res: Response) => { - const userId = req.params.userId; + try { + const userId = req.params.userId; - if (!userId) { - return res.status(400).json({ message: "User ID is required" }); - } + if (!userId) { + return res.status(400).json({ message: "User ID is required" }); + } - try { - await db.collection("user").doc(userId).delete(); + const userRef = db.collection("user").doc(userId); + const userDoc = await userRef.get(); + + if (!userDoc.exists) { + return res.status(404).json({ message: "User not found" }); + } + + await userRef.delete(); return res.json({ message: "User deleted successfully", userId }); } catch (error) { logger.error(error); @@ -197,10 +204,10 @@ router.delete("/:userId", async (req: Request, res: Response) => { export default router; // ユーザー名からユーザー情報を取得 -const getUserFromName = async (userName: string) => { +export const getUserFromName = async (userName: string) => { return await db.collection("user").where("name", "==", userName).get(); }; -const getUserFromId = async (userId: string) => { +export const getUserFromId = async (userId: string) => { return await db.collection("user").doc(userId).get(); }; diff --git a/functions/src/routers/status.ts b/functions/src/status.ts similarity index 100% rename from functions/src/routers/status.ts rename to functions/src/status.ts diff --git a/functions/src/routers/types.ts b/functions/src/types.ts similarity index 96% rename from functions/src/routers/types.ts rename to functions/src/types.ts index bdd99b8..c067b2c 100644 --- a/functions/src/routers/types.ts +++ b/functions/src/types.ts @@ -24,7 +24,7 @@ export interface GoalWithIdAndUserData extends Goal { export interface Post { userId?: string; - storedURL: string; + storedId: string; text: string; submittedAt: Date; } diff --git a/src/Components/Account/LoggedInView.tsx b/src/Components/Account/LoggedInView.tsx index 671424a..4f24fd6 100644 --- a/src/Components/Account/LoggedInView.tsx +++ b/src/Components/Account/LoggedInView.tsx @@ -43,7 +43,9 @@ export default function LoggedInView() { return ( <> {user.loginType === "Guest" ? ( - <>ゲストとしてログイン中 + + ゲストとしてログイン中 + ) : ( <> diff --git a/src/Components/DashBoard/DashBoard.tsx b/src/Components/DashBoard/DashBoard.tsx index e89df23..40d3265 100644 --- a/src/Components/DashBoard/DashBoard.tsx +++ b/src/Components/DashBoard/DashBoard.tsx @@ -11,7 +11,9 @@ import { useEffect, useState } from "react"; import Progress from "../Progress/Progress"; import styles from "./DashBoard.module.scss"; -// 投稿を取得してProgress +// eslint-disable-next-line @typescript-eslint/no-empty-function +let rerenderDashBoard: () => void = () => {}; + export default function DashBoard({ userId = "", success = true, @@ -37,8 +39,9 @@ export default function DashBoard({ const [noResult, setNoResult] = useState(false); const [isLoading, setIsLoading] = useState(true); - useEffect(() => { - fetchResult({ userId }) + const fetchData = () => { + setIsLoading(true); + fetchResult({ userId, success, failed, pending }) .then((data) => { setSuccessResults(data.successResults); setFailedResults(data.failedResults); @@ -54,10 +57,14 @@ export default function DashBoard({ type: "warning", }); }); - }, [userId]); + }; + + useEffect(() => { + rerenderDashBoard = fetchData; + fetchData(); + }, [userId, success, failed, pending]); useEffect(() => { - // 表示したい項目にデータがない場合はnoResultをtrueにする setNoResult( ((success && successResults.length === 0) || !success) && ((failed && failedResults.length === 0) || !failed) && @@ -76,7 +83,7 @@ export default function DashBoard({ }} /> ) : noResult ? ( - + +ボタンから目標を作成しましょう! ) : ( @@ -92,3 +99,7 @@ export default function DashBoard({ ); } + +export function triggerDashBoardRerender() { + rerenderDashBoard(); +} diff --git a/src/Components/DeleteGoalModal/DeleteGoalModal.tsx b/src/Components/DeleteGoalModal/DeleteGoalModal.tsx index ca681b9..cb79308 100644 --- a/src/Components/DeleteGoalModal/DeleteGoalModal.tsx +++ b/src/Components/DeleteGoalModal/DeleteGoalModal.tsx @@ -1,5 +1,6 @@ "use client"; import { appCheckToken, functionsEndpoint } from "@/app/firebase"; +import { triggerDashBoardRerender } from "@/Components/DashBoard/DashBoard"; // インポートパスを修正 import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import { DialogContent, DialogTitle, Modal, ModalDialog } from "@mui/joy"; import JoyButton from "@mui/joy/Button"; @@ -31,6 +32,7 @@ export default function DeleteGoalModal({ goalId }: { goalId: string }) { message: "目標を削除しました", type: "success", }); + triggerDashBoardRerender(); } }; diff --git a/src/Components/DeletePostModal/DeletePostModal.tsx b/src/Components/DeletePostModal/DeletePostModal.tsx index 162ff45..9c708aa 100644 --- a/src/Components/DeletePostModal/DeletePostModal.tsx +++ b/src/Components/DeletePostModal/DeletePostModal.tsx @@ -6,6 +6,7 @@ import JoyButton from "@mui/joy/Button"; import Button from "@mui/material/Button"; import Stack from "@mui/material/Stack"; import { useState } from "react"; +import { triggerDashBoardRerender } from "../DashBoard/DashBoard"; import { showSnackBar } from "../SnackBar/SnackBar"; export default function DeletePostModal({ @@ -37,6 +38,7 @@ export default function DeletePostModal({ message: "目標を削除しました", type: "success", }); + triggerDashBoardRerender(); } }; diff --git a/src/Components/GoalModal/CreateGoalModal.tsx b/src/Components/GoalModal/CreateGoalModal.tsx index 9c83f48..35380e8 100644 --- a/src/Components/GoalModal/CreateGoalModal.tsx +++ b/src/Components/GoalModal/CreateGoalModal.tsx @@ -15,6 +15,7 @@ import { Typography, } from "@mui/joy"; import React, { useEffect, useState } from "react"; +import { triggerDashBoardRerender } from "../DashBoard/DashBoard"; export default function CreateGoalModal({ open, @@ -81,6 +82,7 @@ export default function CreateGoalModal({ message: "目標を作成しました", type: "success", }); + triggerDashBoardRerender(); setText(""); setDueDate(""); diff --git a/src/Components/Loader/Loader.tsx b/src/Components/Loader/Loader.tsx index e2495fa..ad44cca 100644 --- a/src/Components/Loader/Loader.tsx +++ b/src/Components/Loader/Loader.tsx @@ -18,10 +18,9 @@ export const Loader = ({ children }: LoaderProps) => { redirect("/account"); } - // 10秒経ったらボタンを表示 setTimeout(() => { setShowErrorButton(true); - }, 6000); + }, 10000); return ( <> @@ -54,7 +53,7 @@ export const Loader = ({ children }: LoaderProps) => { > 認証エラーが発生した可能性があります。
diff --git a/src/Components/NameUpdate/NameUpdate.tsx b/src/Components/NameUpdate/NameUpdate.tsx index 5cd722e..4a9c911 100644 --- a/src/Components/NameUpdate/NameUpdate.tsx +++ b/src/Components/NameUpdate/NameUpdate.tsx @@ -38,7 +38,9 @@ export default function NameUpdate() { ); if (!response.ok) { - throw new Error("Network response was not ok"); + const status = response.status; + const data = await response.json(); + throw new Error(`Error ${status}: ${data.message}`); } // Firebase AuthenticationのdisplayNameを更新 diff --git a/src/Components/PostModal/PostModal.tsx b/src/Components/PostModal/PostModal.tsx index 6ef879a..eaae85b 100644 --- a/src/Components/PostModal/PostModal.tsx +++ b/src/Components/PostModal/PostModal.tsx @@ -2,7 +2,7 @@ import { showSnackBar } from "@/Components/SnackBar/SnackBar"; import { PostWithGoalId } from "@/types/types"; import { createPost, handleCreatePostError } from "@/utils/API/Post/createPost"; -import { uploadImage } from "@/utils/Uploader"; +import { removeImageMetadata, uploadImage } from "@/utils/Uploader"; import { useUser } from "@/utils/UserContext"; import { Add } from "@mui/icons-material"; import AddAPhotoIcon from "@mui/icons-material/AddAPhoto"; @@ -43,7 +43,7 @@ export default function PostModal({ setText(event.target.value); }; - const handleImageChange = (event: ChangeEvent) => { + const handleImageChange = async (event: ChangeEvent) => { const selectedFile = event.target.files?.[0]; if (!selectedFile) { @@ -56,7 +56,8 @@ export default function PostModal({ // ファイルサイズの上限を設定 const maxSize = 8; // 上限8MB - const fileSizeMB = selectedFile.size / (1024 * 1024); + const fileWithoutMetadata = await removeImageMetadata(selectedFile); // モーションフォトの動画を除いたサイズを取得 + const fileSizeMB = fileWithoutMetadata.size / (1024 * 1024); if (fileSizeMB > maxSize) { showSnackBar({ message: `最大ファイルサイズは${maxSize}MBです。`, @@ -91,10 +92,10 @@ export default function PostModal({ await uploadImage( image, (percent) => setProgress(percent), - async (url) => { + async (url, id) => { const postData: PostWithGoalId = { userId: user?.userId as string, - storedURL: url, + storedId: id, text: text, goalId: goalId, submittedAt: new Date(), diff --git a/src/Components/Progress/Progress.tsx b/src/Components/Progress/Progress.tsx index 20fdace..e83a1f3 100644 --- a/src/Components/Progress/Progress.tsx +++ b/src/Components/Progress/Progress.tsx @@ -5,13 +5,15 @@ import { useUser } from "@/utils/UserContext"; import AppRegistrationRoundedIcon from "@mui/icons-material/AppRegistrationRounded"; import CheckRoundedIcon from "@mui/icons-material/CheckRounded"; import CloseIcon from "@mui/icons-material/Close"; +import { CssVarsProvider, Divider, extendTheme } from "@mui/joy"; import Card from "@mui/joy/Card"; import CardContent from "@mui/joy/CardContent"; +import Skeleton from "@mui/joy/Skeleton"; import Step, { stepClasses } from "@mui/joy/Step"; import StepIndicator, { stepIndicatorClasses } from "@mui/joy/StepIndicator"; import Stepper from "@mui/joy/Stepper"; import Typography, { typographyClasses } from "@mui/joy/Typography"; -import { Divider } from "@mui/material"; +import { getDownloadURL, getStorage, ref } from "firebase/storage"; import { ReactNode, useState } from "react"; import DeleteGoalModal from "../DeleteGoalModal/DeleteGoalModal"; import DeletePostModal from "../DeletePostModal/DeletePostModal"; @@ -115,11 +117,35 @@ const SuccessStep = ({ result: GoalWithIdAndUserData; user: User; }) => { + const [imageURL, setImageURL] = useState(""); + const [imageLoaded, setImageLoaded] = useState(false); + const post = result.post; if (!post) { return null; } + const storage = getStorage(); + const imageRef = ref(storage, `post/${post.storedId}`); + + getDownloadURL(imageRef) + .then((url) => { + setImageURL(url); + }) + .catch((error) => { + console.error("Error fetching image URL:", error); + }); + + const theme = extendTheme({ + components: { + JoySkeleton: { + defaultProps: { + animation: "wave", + }, + }, + }, + }); + return ( - {post.storedURL && ( - + + + {imageURL && ( + setImageLoaded(true)} + /> + )} + + + + +
- )} - -
+ > {formatStringToDate(post.submittedAt)}に完了 @@ -323,7 +366,13 @@ const GoalCard = ({ }} > -
+
{formatStringToDate(deadline)}までに @@ -362,7 +411,7 @@ const StepperBlock = ({ sx={{ width: "87%", margin: "10px auto", - padding: "13px", + padding: "10px 13px", borderRadius: "8px", border: "1px solid", borderColor: diff --git a/src/styles/globals.scss b/src/styles/globals.scss index 8206f44..76ef4cb 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -18,5 +18,5 @@ body { main { background: white; - padding-bottom: 100px; + padding-bottom: 130px; } diff --git a/src/types/types.ts b/src/types/types.ts index a78304f..c7fe0df 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -25,7 +25,7 @@ export interface GoalWithIdAndUserData extends Goal { export interface Post { userId: string; - storedURL: string; + storedId: string; text: string; submittedAt: Date | string; } diff --git a/src/utils/API/Goal/createGoal.ts b/src/utils/API/Goal/createGoal.ts index 6639db0..259f7a0 100644 --- a/src/utils/API/Goal/createGoal.ts +++ b/src/utils/API/Goal/createGoal.ts @@ -18,7 +18,9 @@ export const createGoal = async (postData: Goal) => { }); if (!response.ok) { - throw new Error(`Network response was not ok: ${response.statusText}`); + const status = response.status; + const data = await response.json(); + throw new Error(`Error ${status}: ${data.message}`); } return await response.json(); diff --git a/src/utils/API/Post/createPost.ts b/src/utils/API/Post/createPost.ts index 0c341c7..158e9d1 100644 --- a/src/utils/API/Post/createPost.ts +++ b/src/utils/API/Post/createPost.ts @@ -18,7 +18,9 @@ export const createPost = async (postData: PostWithGoalId) => { }); if (!response.ok) { - throw new Error(`Network response was not ok: ${response.statusText}`); + const status = response.status; + const data = await response.json(); + throw new Error(`Error ${status}: ${data.message}`); } return await response.json(); diff --git a/src/utils/API/Result/fetchResult.ts b/src/utils/API/Result/fetchResult.ts index 05b6a4c..247bc69 100644 --- a/src/utils/API/Result/fetchResult.ts +++ b/src/utils/API/Result/fetchResult.ts @@ -9,18 +9,39 @@ import { appCheckToken, functionsEndpoint } from "@/app/firebase"; */ export const fetchResult = async ({ userId = "", -}: { userId?: string } = {}) => { - const response = await fetch(`${functionsEndpoint}/result/${userId}`, { - method: "GET", - headers: { - "X-Firebase-AppCheck": appCheckToken, - "Content-Type": "application/json", - }, - }); + success = true, + failed = true, + pending = true, +}: { + userId?: string; + success?: boolean; + failed?: boolean; + pending?: boolean; +} = {}) => { + const queryParams = new URLSearchParams(); + if (!success && !failed && pending) { + queryParams.append("onlyPending", "true"); + } else if (success && failed && !pending) { + queryParams.append("onlyFinished", "true"); + } + + const response = await fetch( + `${functionsEndpoint}/result/${userId}?${queryParams.toString()}`, + { + method: "GET", + headers: { + "X-Firebase-AppCheck": appCheckToken, + "Content-Type": "application/json", + }, + } + ); if (!response.ok) { - throw new Error("Network response was not ok"); + const status = response.status; + const data = await response.json(); + throw new Error(`Error ${status}: ${data.message}`); } + const data = await response.json(); return data; }; diff --git a/src/utils/API/User/createUser.ts b/src/utils/API/User/createUser.ts index 93176a7..08e434f 100644 --- a/src/utils/API/User/createUser.ts +++ b/src/utils/API/User/createUser.ts @@ -18,8 +18,11 @@ export const createUser = async (name: string, userId: string) => { }); if (!response.ok) { - throw new Error("Network response was not ok"); + const status = response.status; + const data = await response.json(); + throw new Error(`Error ${status}: ${data.message}`); } + const data = await response.json(); console.log("Success:", data); }; diff --git a/src/utils/API/User/fetchUser.ts b/src/utils/API/User/fetchUser.ts index f90002d..d061cf2 100644 --- a/src/utils/API/User/fetchUser.ts +++ b/src/utils/API/User/fetchUser.ts @@ -17,8 +17,11 @@ export const fetchUserById = async (userId: string): Promise => { }); if (!response.ok) { - throw new Error("Network response was not ok"); + const status = response.status; + const data = await response.json(); + throw new Error(`Error ${status}: ${data.message}`); } + const data = await response.json(); return data; }; @@ -30,16 +33,19 @@ export const fetchUserById = async (userId: string): Promise => { * @return {*} */ export const handleFetchUserError = (error: unknown) => { - let snackBarMessage = "ユーザー情報の取得に失敗しました"; + let snackBarMessage = "初回ログインかユーザーデータが見つかりません"; if (error instanceof Error) { console.error("Fetch error:", error.message); if (error.message.includes("404")) { - snackBarMessage = "ユーザーが見つかりませんでした"; + snackBarMessage = "ユーザー情報が登録されていません"; } if (error.message.includes("500")) { snackBarMessage = "サーバーエラーが発生しました"; } + if (error.message.includes("429")) { + snackBarMessage = "リクエストが多すぎます。数分後に再度お試しください"; + } } else { console.error("An unknown error occurred"); snackBarMessage = "不明なエラーが発生しました"; diff --git a/src/utils/Uploader.ts b/src/utils/Uploader.ts index 7737f03..13b441f 100644 --- a/src/utils/Uploader.ts +++ b/src/utils/Uploader.ts @@ -10,24 +10,74 @@ export const uploadImage = ( throw new Error("ファイルが選択されていません"); } - // cryptoモジュールを使用してユニークなIDを生成 - const uniqueId = crypto.randomUUID(); - const storageRef = ref(storage, `post/${uniqueId}`); - const uploadTask = uploadBytesResumable(storageRef, file); + // メタデータを削除してからFirebaseにアップロード + removeImageMetadata(file) + .then((cleanFile) => { + const uniqueId = crypto.randomUUID(); + const storageRef = ref(storage, `post/${uniqueId}`); + const uploadTask = uploadBytesResumable(storageRef, cleanFile); - uploadTask.on( - "state_changed", - (snapshot) => { - const percent = (snapshot.bytesTransferred / snapshot.totalBytes) * 100; - onProgress(percent); - }, - (error) => { - throw new Error("ファイルアップに失敗しました。エラー: " + error.message); - }, - () => { - getDownloadURL(uploadTask.snapshot.ref).then((downloadURL) => { - onSuccess(downloadURL, uniqueId); // 完了時にURLとIDを返す - }); - } - ); + uploadTask.on( + "state_changed", + (snapshot) => { + const percent = + (snapshot.bytesTransferred / snapshot.totalBytes) * 100; + onProgress(percent); + }, + (error) => { + throw new Error( + "ファイルアップに失敗しました。エラー: " + error.message + ); + }, + () => { + getDownloadURL(uploadTask.snapshot.ref).then((downloadURL) => { + onSuccess(downloadURL, uniqueId); // 完了時にURLとIDを返す + }); + } + ); + }) + .catch((error) => { + throw new Error( + "画像のメタデータ削除に失敗しました。エラー: " + error.message + ); + }); +}; + +// 画像のメタデータを削除する関数 +// モーションフォトの動画も削除できる +export const removeImageMetadata = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.drawImage(img, 0, 0); + + canvas.toBlob((blob) => { + if (blob) { + const cleanFile = new File([blob], file.name, { + type: file.type, + }); + resolve(cleanFile); + } else { + reject(new Error("画像処理に失敗しました。")); + } + }, file.type); + } else { + reject(new Error("Canvas のコンテキストを取得できません。")); + } + }; + img.src = event.target?.result as string; + }; + reader.onerror = () => { + reject(new Error("画像を読み込めませんでした。")); + }; + reader.readAsDataURL(file); + }); }; diff --git a/src/utils/UserContext.tsx b/src/utils/UserContext.tsx index f75b4b1..25f628f 100644 --- a/src/utils/UserContext.tsx +++ b/src/utils/UserContext.tsx @@ -1,7 +1,11 @@ "use client"; import { auth } from "@/app/firebase"; +import { showSnackBar } from "@/Components/SnackBar/SnackBar"; import { LoginType, User } from "@/types/types"; -import { fetchUserById } from "@/utils/API/User/fetchUser"; +import { + fetchUserById, + handleFetchUserError, +} from "@/utils/API/User/fetchUser"; import { User as FirebaseUser, onAuthStateChanged } from "firebase/auth"; import { createContext, @@ -96,11 +100,11 @@ export const UserProvider = ({ children }: Props) => { } } catch (error: unknown) { console.error("ユーザーデータの取得に失敗しました:", error); - // const message = handleFetchUserError(error); - // showSnackBar({ - // message, - // type: "warning", - // }); + const message = handleFetchUserError(error); + showSnackBar({ + message, + type: "warning", + }); } } );