diff --git a/Documents/API.md b/Documents/API.md index 96f815ff..9bd3463f 100644 --- a/Documents/API.md +++ b/Documents/API.md @@ -68,7 +68,7 @@ API is provided by Firebase Cloud Functions. Database is provided by Firestore. - URL: /user/:userId - Method: DELETE -## Goals +## Goal ### Create Goal - URL: /goal - Method: POST @@ -141,7 +141,7 @@ API is provided by Firebase Cloud Functions. Database is provided by Firestore. - URL: /goal/:goalId - Method: DELETE -## Posts +## Post ### Create Post - URL: /post - Method: POST @@ -258,3 +258,20 @@ Use Create Post API to update post. ] } ``` + +## Reactions +- URL: /reaction/:goalId +- Method: PUT +- Request + - Headers + - Content-Type: application/json + - Body + - userId: string + - reaction: string + - Example + ```json + { + "userId": "IK0Zc2hoUYaYjXoqzmCl", + "reactionType": "clap" + } + ``` diff --git a/Documents/Database.md b/Documents/Database.md new file mode 100644 index 00000000..f1a95ba8 --- /dev/null +++ b/Documents/Database.md @@ -0,0 +1,25 @@ +データベースには`user`と`goal`の2つのコレクションがあります。`user`コレクションにはユーザーの情報が、`goal`コレクションにはユーザーが設定した目標と結果の情報が保存されます。 + +# userコレクション +user (コレクション) +├── userId (ドキュメント) +│ ├── fcmToken: string (初期値: "") +│ ├── name: string +│ └── streak: number (初期値: 0, 目標完了時に更新, streakがリセットされる場合はAPI側で0を返す) +├── 他のドキュメント... + + +# goalコレクション +goal (コレクション) +├── goalId (ドキュメント) +│ ├── userId: string +│ ├── deadline: Timestamp (例: 2024年12月18日 23:00:00 UTC+9) +│ ├── text: string (""は禁止) +│ ├── post: map | null (初期値: null) +│ │ ├── storedId: string (storageに保存されたファイルのID) +│ │ ├── submittedAt: Timestamp (目標完了時間) +│ │ └── text: string (""もOK) +│ └── reaction?: map (初期値: undefined) +│ ├── userId: string (リアクションの種類) +│ ├── 他のUserId... +├── 他のドキュメント... diff --git a/README.md b/README.md index b61ab740..9acae6c0 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ firebase emulators:start ||Document in this project or related PR|Firebase/Google Cloud - Official Documents| |-|-|-| |API|[API Document](./Documents/API.md)|[Firebase Cloud Functions](https://firebase.google.com/docs/functions)| -|Database||[Firestore](https://firebase.google.com/docs/firestore)| +|Database|[Database Document](./Documents/Database.md)|[Firestore](https://firebase.google.com/docs/firestore)| |Storage||[Cloud Storage for Firebase](https://firebase.google.com/docs/storage)| |Authentication||[Firebase Authentication](https://firebase.google.com/docs/auth)| |Receive Notification|[Cloud Messagingを実装 #49](https://github.com/MurakawaTakuya/todo-real/pull/49)|[Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging/)| diff --git a/functions/src/index.ts b/functions/src/index.ts index bd8e177f..c84668e7 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -15,6 +15,7 @@ admin.initializeApp({ import goalRouter from "./routers/goalRouter"; import notificationRouter from "./routers/notificationRouter"; import postRouter from "./routers/postRouter"; +import reactionRouter from "./routers/reactionRouter"; import resultRouter from "./routers/resultRouter"; import userRouer from "./routers/userRouter"; @@ -100,6 +101,7 @@ app.use("/goal", goalRouter); app.use("/post", postRouter); app.use("/result", resultRouter); app.use("/notification", notificationRouter); +app.use("/reaction", reactionRouter); const region = "asia-northeast1"; diff --git a/functions/src/routers/goalRouter.ts b/functions/src/routers/goalRouter.ts index 051a53ff..bf499d93 100644 --- a/functions/src/routers/goalRouter.ts +++ b/functions/src/routers/goalRouter.ts @@ -23,6 +23,7 @@ router.get("/", async (req: Request, res: Response) => { userId: data.userId, text: data.text, post: data.post, + reaction: data.reaction ?? null, }; }); @@ -37,10 +38,6 @@ router.get("/", async (req: Request, res: Response) => { router.get("/:userId", async (req: Request, res: Response) => { const userId = req.params.userId; - if (!userId) { - return res.status(400).json({ message: "User ID is required" }); - } - try { const goalSnapshot = await db .collection("goal") @@ -59,6 +56,7 @@ router.get("/:userId", async (req: Request, res: Response) => { deadline: new Date(data.deadline._seconds * 1000), text: data.text, post: data.post, + reaction: data.reaction ?? null, }; }); @@ -115,6 +113,10 @@ router.put("/:goalId", async (req: Request, res: Response) => { const goalId = req.params.goalId; const { userId, deadline, text }: Partial = req.body; + if (!goalId) { + return res.status(400).json({ message: "goalId is required" }); + } + if (!userId && !deadline && !text) { return res.status(400).json({ message: "At least one of userId, deadline, or text is required", @@ -151,7 +153,7 @@ router.delete("/:goalId", async (req: Request, res: Response) => { const goalId = req.params.goalId; if (!goalId) { - return res.status(400).json({ message: "Goal ID is required" }); + return res.status(400).json({ message: "goalId is required" }); } const goalRef = db.collection("goal").doc(goalId); diff --git a/functions/src/routers/postRouter.ts b/functions/src/routers/postRouter.ts index d17c11f1..15da3f56 100644 --- a/functions/src/routers/postRouter.ts +++ b/functions/src/routers/postRouter.ts @@ -46,10 +46,6 @@ router.get("/", async (req: Request, res: Response) => { router.get("/:userId", async (req: Request, res: Response) => { const userId = req.params.userId; - if (!userId) { - return res.status(400).json({ message: "User ID is required" }); - } - try { const goalSnapshot = await db .collection("goal") @@ -139,7 +135,7 @@ router.delete("/:goalId", async (req: Request, res: Response) => { const goalId = req.params.goalId; if (!goalId) { - return res.status(400).json({ message: "Goal ID is required" }); + return res.status(400).json({ message: "goalId is required" }); } const goalRef = db.collection("goal").doc(goalId); @@ -167,7 +163,7 @@ router.delete("/:goalId", async (req: Request, res: Response) => { post: null, }); - return res.json({ message: "Post deleted successfully" }); + return res.json({ message: "Post deleted successfully", goalId }); } catch (error) { logger.error(error); return res.status(500).json({ message: "Error deleting post" }); diff --git a/functions/src/routers/reactionRouter.ts b/functions/src/routers/reactionRouter.ts new file mode 100644 index 00000000..b3b98b82 --- /dev/null +++ b/functions/src/routers/reactionRouter.ts @@ -0,0 +1,61 @@ +import express, { Request, Response } from "express"; +import admin from "firebase-admin"; +import { logger } from "firebase-functions"; +import { Reaction, reactionTypeMap } from "../types"; + +const router = express.Router(); +const db = admin.firestore(); + +// PUT: リアクションを更新 +router.put("/:goalId", async (req: Request, res: Response) => { + const goalId = req.params.goalId; + const { userId, reactionType }: Partial = req.body; + + if (!userId || !goalId) { + return res.status(400).json({ + message: "userID and goalId are required", + }); + } + + try { + const goalRef = db.collection("goal").doc(goalId); + const goalDoc = await goalRef.get(); + + if (!goalDoc.exists) { + return res.status(404).json({ message: "Goal not found" }); + } + + const goalData = goalDoc.data(); + + const currentReactions = goalData?.post?.reaction || {}; + if (!reactionType) { + // リアクション削除 + delete currentReactions[userId]; + } else { + // リアクション追加 + const validReactionTypes = [ + ...reactionTypeMap.success, + ...reactionTypeMap.failed, + ]; + if (!validReactionTypes.includes(reactionType)) { + return res.status(400).json({ message: "Invalid reactionType" }); + } + + currentReactions[userId] = reactionType; + } + + await goalRef.update({ + post: { + ...goalData?.post, + reaction: currentReactions, + }, + }); + + return res.json({ message: "Reaction updated successfully", goalId }); + } catch (error) { + logger.error(error); + return res.status(500).json({ message: "Error updating Reaction" }); + } +}); + +export default router; diff --git a/functions/src/routers/resultRouter.ts b/functions/src/routers/resultRouter.ts index 2567f967..ef90b6c5 100644 --- a/functions/src/routers/resultRouter.ts +++ b/functions/src/routers/resultRouter.ts @@ -119,6 +119,7 @@ const processGoals = async ( storedId: post.storedId, submittedAt: post.submittedAt.toDate(), }, + reaction: data.reaction || null, userData, }); } diff --git a/functions/src/routers/userRouter.ts b/functions/src/routers/userRouter.ts index c9de9f08..1c55c39f 100644 --- a/functions/src/routers/userRouter.ts +++ b/functions/src/routers/userRouter.ts @@ -45,10 +45,6 @@ router.get("/", async (req: Request, res: Response) => { router.get("/id/:userId", async (req: Request, res: Response) => { const userId = req.params.userId; - if (!userId) { - return res.status(400).json({ message: "User ID is required" }); - } - try { const userDoc = await getUserFromId(userId); @@ -154,6 +150,10 @@ router.put("/:userId", async (req: Request, res: Response) => { const userId = req.params.userId; const { name, fcmToken }: Partial = req.body; + if (!userId) { + return res.status(400).json({ message: "userId is required" }); + } + if (!name && fcmToken === undefined) { return res.status(400).json({ message: "At least one of name, streak, or fcmToken is required", @@ -183,7 +183,7 @@ router.delete("/:userId", async (req: Request, res: Response) => { const userId = req.params.userId; if (!userId) { - return res.status(400).json({ message: "User ID is required" }); + return res.status(400).json({ message: "userId is required" }); } const userRef = db.collection("user").doc(userId); diff --git a/functions/src/status.ts b/functions/src/status.ts index ddd6b3e6..f11c9919 100644 --- a/functions/src/status.ts +++ b/functions/src/status.ts @@ -6,7 +6,7 @@ const db = admin.firestore(); // 完了した目標をカウント(postがnullでないものをカウント) export const countCompletedGoals = async (userId: string): Promise => { if (!userId) { - throw new Error("User ID is required"); + throw new Error("goalId is required"); } try { @@ -27,7 +27,7 @@ export const countCompletedGoals = async (userId: string): Promise => { // 失敗した目標をカウント(今より前の`deadline`を持ち、postがnullの目標の数をカウント) export const countFailedGoals = async (userId: string): Promise => { if (!userId) { - throw new Error("User ID is required"); + throw new Error("goalId is required"); } try { @@ -55,7 +55,7 @@ export const hasCompletedGoalWithinRange = async ( endDate: Date ): Promise => { if (!userId) { - throw new Error("User ID is required"); + throw new Error("goalId is required"); } try { diff --git a/functions/src/tasks.ts b/functions/src/tasks.ts index 1456dc26..c377552b 100644 --- a/functions/src/tasks.ts +++ b/functions/src/tasks.ts @@ -32,7 +32,7 @@ export const createTasksOnGoalCreate = onDocumentCreated( try { const goalData = event.data.data(); - const marginTime = 5; + const marginTime = 10; // 期限のmarginTime分前にタスクを設定 const deadline = new Date( goalData.deadline.toDate().getTime() - marginTime * 60 * 1000 diff --git a/functions/src/types.ts b/functions/src/types.ts index c067b2c1..45f04b88 100644 --- a/functions/src/types.ts +++ b/functions/src/types.ts @@ -11,6 +11,7 @@ export interface Goal { deadline: Date; text: string; post: Post | null; + reaction: Reaction | null; } export interface GoalWithId extends Goal { @@ -32,3 +33,18 @@ export interface Post { export interface PostWithGoalId extends Post { goalId: string; // Postの所属するGoalId } + +export interface Reaction { + userId: string; + reactionType: ReactionType["success"] | ReactionType["failed"]; +} + +export interface ReactionType { + success: "laugh" | "surprised" | "clap"; + failed: "sad" | "angry" | "muscle"; +} + +export const reactionTypeMap = { + success: ["laugh", "surprised", "clap"] as const, + failed: ["sad", "angry", "muscle"] as const, +};