diff --git a/packages/backend_app/openapi.json b/packages/backend_app/openapi.json index 907a542c..8e0f3d52 100644 --- a/packages/backend_app/openapi.json +++ b/packages/backend_app/openapi.json @@ -54,18 +54,17 @@ "created_at": { "type": "string", "format": "date-time", - "description": "The date and time the post was created", + "description": "The date and time the post was originally created", "example": "2025-01-01T00:00:00Z" }, "updated_at": { "type": "string", "format": "date-time", - "description": "The date and time the post was updated", + "description": "The date and time the post was created (or last updated in case of delete&insert)", "example": "2025-01-01T00:00:00Z" } }, - "required": ["public_id", "content", "created_at", "updated_at"], - "additionalProperties": false + "required": ["public_id", "content", "created_at", "updated_at"] } }, "parameters": {} @@ -399,8 +398,7 @@ "properties": { "user": { "$ref": "#/components/schemas/user" } }, - "required": ["user"], - "additionalProperties": false + "required": ["user"] } ] } @@ -582,8 +580,7 @@ "properties": { "user": { "$ref": "#/components/schemas/user" } }, - "required": ["user"], - "additionalProperties": false + "required": ["user"] } ] } @@ -752,7 +749,8 @@ "description": "The content of the post", "example": "test" } - } + }, + "required": ["content"] } } } @@ -773,8 +771,7 @@ "properties": { "user": { "$ref": "#/components/schemas/user" } }, - "required": ["user"], - "additionalProperties": false + "required": ["user"] } ] } diff --git a/packages/backend_app/src/apps/posts/dto.ts b/packages/backend_app/src/apps/posts/dto.ts index 095d674a..2b420753 100644 --- a/packages/backend_app/src/apps/posts/dto.ts +++ b/packages/backend_app/src/apps/posts/dto.ts @@ -1,47 +1,69 @@ import { z } from "@hono/zod-openapi"; -import { postPublicFields } from "../../db/field"; import { postsTable } from "../../db/schema"; -import { - createInsertSchema, - createSelectSchema, - createUpdateSchema, -} from "../factory"; +import { createInsertSchema, createUpdateSchema } from "../factory"; import { userSelectSchema } from "../user/dto"; -const postSelectSchema = createSelectSchema(postsTable, { - public_id: (schema) => - schema.openapi({ +export const postSchema = z + .object({ + public_id: z.uuid().openapi({ description: "Public ID", example: "123e4567-e89b-12d3-a456-426614174000", }), - content: (schema) => - schema.openapi({ + content: z.string().openapi({ description: "The content of the post", example: "test", }), - created_at: (schema) => - schema.openapi({ - description: "The date and time the post was created", + created_at: z.iso.datetime().openapi({ + description: "The date and time the post was originally created", example: "2025-01-01T00:00:00Z", format: "date-time", }), - updated_at: (schema) => - schema.openapi({ - description: "The date and time the post was updated", + updated_at: z.iso.datetime().openapi({ + description: + "The date and time the post was created (or last updated in case of delete&insert)", example: "2025-01-01T00:00:00Z", format: "date-time", }), -}) - .pick(postPublicFields) - .strict() + }) .openapi("post"); -export const postWithUserSelectSchema = postSelectSchema.extend({ - user: userSelectSchema, +// DB データから API レスポンス形式への変換関数 +export const transformPost = (post: { + public_id: string; + content: string; + first_created_at: Date; + created_at: Date; +}): z.infer => ({ + public_id: post.public_id, + content: post.content, + created_at: post.first_created_at.toISOString(), // first_created_at → created_at + updated_at: post.created_at.toISOString(), // created_at → updated_at }); +// user 付きの変換関数 +export const transformPostWithUser = < + T extends { + public_id: string; + content: string; + first_created_at: Date; + created_at: Date; + user: z.infer; + }, +>( + post: T, +): z.infer => ({ + ...transformPost(post), + user: post.user, +}); + +export const postWithUserResponseSchema = postSchema.and( + z.object({ + user: userSelectSchema, + }), +); + export const getPostsResponseSchema = z.object({ - posts: postWithUserSelectSchema.array(), + posts: postWithUserResponseSchema.array(), }); export const postPostRequestSchema = createInsertSchema(postsTable, { @@ -55,7 +77,7 @@ export const postPostRequestSchema = createInsertSchema(postsTable, { }); export const postPostResponseSchema = z.object({ - post: postWithUserSelectSchema, + post: postWithUserResponseSchema, }); export const updatePostParamsSchema = z.object({ @@ -68,9 +90,11 @@ export const updatePostRequestSchema = createUpdateSchema(postsTable, { description: "The content of the post", example: "test", }), -}).pick({ - content: true, -}); +}) + .pick({ + content: true, + }) + .required(); export const updatePostResponseSchema = postPostResponseSchema; diff --git a/packages/backend_app/src/apps/posts/index.test.ts b/packages/backend_app/src/apps/posts/index.test.ts index 3f986ca0..02e1506b 100644 --- a/packages/backend_app/src/apps/posts/index.test.ts +++ b/packages/backend_app/src/apps/posts/index.test.ts @@ -4,7 +4,7 @@ import type { ClientRequestOptions } from "hono/client"; import { testClient } from "hono/testing"; import { app } from "../../apps"; import { db } from "../../db"; -import { postsTable, usersTable } from "../../db/schema"; +import { postLogsTable, postsTable, usersTable } from "../../db/schema"; import { supabaseUid } from "../../test/supabase"; describe("postsApp", () => { @@ -21,6 +21,7 @@ describe("postsApp", () => { await db .insert(usersTable) .values({ + public_id: faker.string.uuid(), supabase_uid: supabaseUid, display_name: faker.person.fullName(), }) @@ -33,6 +34,7 @@ describe("postsApp", () => { await db .insert(usersTable) .values({ + public_id: faker.string.uuid(), supabase_uid: faker.string.uuid(), display_name: faker.person.fullName(), }) @@ -58,12 +60,19 @@ describe("postsApp", () => { describe("when there are some posts", () => { beforeEach(async () => { - await db - .insert(postsTable) - .values({ user_id: user.id, content: "test" }); - await db - .insert(postsTable) - .values({ user_id: user.id, content: "test2" }); + const now = new Date(); + await db.insert(postsTable).values({ + public_id: faker.string.uuid(), + user_id: user.id, + content: "test", + first_created_at: now, + }); + await db.insert(postsTable).values({ + public_id: faker.string.uuid(), + user_id: user.id, + content: "test2", + first_created_at: now, + }); }); it("should return 200 Response", async () => { @@ -76,8 +85,8 @@ describe("postsApp", () => { expect(json.posts[0]).toEqual({ public_id: expect.any(String), content: "test2", - created_at: expect.any(String), - updated_at: expect.any(String), + created_at: expect.any(String), // first_created_at → created_at に変換 + updated_at: expect.any(String), // created_at → updated_at に変換 user: { public_id: user.public_id, display_name: user.display_name, @@ -86,8 +95,8 @@ describe("postsApp", () => { expect(json.posts[1]).toEqual({ public_id: expect.any(String), content: "test", - created_at: expect.any(String), - updated_at: expect.any(String), + created_at: expect.any(String), // first_created_at → created_at に変換 + updated_at: expect.any(String), // created_at → updated_at に変換 user: { public_id: user.public_id, display_name: user.display_name, @@ -133,7 +142,18 @@ describe("postsApp", () => { const post = posts[0]; if (!post) throw new Error("post is not found"); expect(post.content).toBe(content); - expect(post.created_at).toEqual(post.updated_at); + + const postLogs = await db.select().from(postLogsTable); + expect(postLogs).toHaveLength(1); + const postLog = postLogs[0]; + if (!postLog) throw new Error("postLog is not found"); + expect(postLog.public_id).toBe(post.public_id); + expect(postLog.user_id).toBe(post.user_id); + expect(postLog.content).toBe(content); + + // first_created_atが正しく設定されていることを確認 + expect(post.first_created_at).toBeDefined(); + expect(post.first_created_at).toBeInstanceOf(Date); }); }); @@ -189,10 +209,21 @@ describe("postsApp", () => { }); describe("when post is found", () => { + let firstPost: typeof postsTable.$inferSelect; + beforeEach(async () => { - await db + const result = await db .insert(postsTable) - .values({ public_id, user_id: user.id, content: "test" }); + .values({ + public_id, + user_id: user.id, + content: "test", + first_created_at: new Date(), + }) + .returning(); + const post = result[0]; + if (!post) throw new Error("post is not found"); + firstPost = post; }); it("should return 200 Response", async () => { @@ -209,17 +240,30 @@ describe("postsApp", () => { const post = posts[0]; if (!post) throw new Error("post is not found"); expect(post.content).toBe(content); - expect(post.created_at.getTime()).toBeLessThan( - post.updated_at.getTime(), - ); + expect(post.public_id).toBe(public_id); + + // first_created_atが維持されていることを確認 + expect(post.first_created_at).toEqual(firstPost.first_created_at); + + const postLogs = await db.select().from(postLogsTable); + expect(postLogs).toHaveLength(1); + const postLog = postLogs[0]; + if (!postLog) throw new Error("postLog is not found"); + expect(postLog.id).toBe(post.id); + expect(postLog.public_id).toBe(post.public_id); + expect(postLog.user_id).toBe(post.user_id); + expect(postLog.content).toBe(content); }); }); describe("when post user is not the same as the current user", () => { beforeEach(async () => { - await db - .insert(postsTable) - .values({ public_id, user_id: anotherUser.id, content: "test" }); + await db.insert(postsTable).values({ + public_id, + user_id: anotherUser.id, + content: "test", + first_created_at: new Date(), + }); }); it("should return 403 Response", async () => { @@ -289,9 +333,12 @@ describe("postsApp", () => { describe("when post is found", () => { beforeEach(async () => { - await db - .insert(postsTable) - .values({ public_id, user_id: user.id, content: "test" }); + await db.insert(postsTable).values({ + public_id, + user_id: user.id, + content: "test", + first_created_at: new Date(), + }); }); it("should return 200 Response", async () => { @@ -305,9 +352,12 @@ describe("postsApp", () => { describe("when post user is not the same as the current user", () => { beforeEach(async () => { - await db - .insert(postsTable) - .values({ public_id, user_id: anotherUser.id, content: "test" }); + await db.insert(postsTable).values({ + public_id, + user_id: anotherUser.id, + content: "test", + first_created_at: new Date(), + }); }); it("should return 403 Response", async () => { diff --git a/packages/backend_app/src/apps/posts/index.ts b/packages/backend_app/src/apps/posts/index.ts index 0ad81408..ab8f2fe4 100644 --- a/packages/backend_app/src/apps/posts/index.ts +++ b/packages/backend_app/src/apps/posts/index.ts @@ -2,14 +2,10 @@ import { desc, eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { db } from "../../db"; import { postWithUserQuery } from "../../db/query"; -import { postsTable } from "../../db/schema"; +import { postLogsTable, postsTable } from "../../db/schema"; import { userMiddleware } from "../../middlewares/user"; import { createApp } from "../factory"; -import { - getPostsResponseSchema, - postPostResponseSchema, - updatePostResponseSchema, -} from "./dto"; +import { transformPostWithUser } from "./dto"; import { deletePostRoute, getPostsRoute, @@ -28,29 +24,51 @@ const routes = postApp ...postWithUserQuery, orderBy: desc(postsTable.id), }); - const response = { posts }; - return c.json(getPostsResponseSchema.parse(response), 200); + return c.json({ posts: posts.map(transformPostWithUser) }, 200); }) .openapi(postPostRoute, async (c) => { const { content } = c.req.valid("json"); const user = c.get("user"); - const result = await db - .insert(postsTable) - .values({ user_id: user.id, content }) - .returning(); - const post = result[0]; - if (!post) + const result = await db.transaction(async (tx) => { + const now = new Date(); + const post = ( + await tx + .insert(postsTable) + .values({ + public_id: crypto.randomUUID(), + user_id: user.id, + content, + first_created_at: now, // 最初の作成日時を設定 + }) + .returning() + )[0]; + if (!post) + throw new HTTPException(500, { + message: "Failed to create post", + }); + + await tx.insert(postLogsTable).values({ + id: post.id, + public_id: post.public_id, + user_id: post.user_id, + content: post.content, + created_at: post.created_at, + }); + + return post; + }); + + const post = await db.query.postsTable.findFirst({ + ...postWithUserQuery, + where: eq(postsTable.id, result.id), + }); + if (!post) { throw new HTTPException(500, { - message: "Failed to create post", + message: "Failed to fetch created post", }); - const response = { - post: await db.query.postsTable.findFirst({ - ...postWithUserQuery, - where: eq(postsTable.id, post.id), - }), - }; - return c.json(postPostResponseSchema.parse(response), 200); + } + return c.json({ post: transformPostWithUser(post) }, 200); }) .openapi(updatePostRoute, async (c) => { @@ -58,7 +76,7 @@ const routes = postApp const { content } = c.req.valid("json"); const user = c.get("user"); - const post = await db.transaction(async (tx) => { + await db.transaction(async (tx) => { const target = ( await tx .select() @@ -78,10 +96,16 @@ const routes = postApp }); } + await tx.delete(postsTable).where(eq(postsTable.public_id, public_id)); + const results = await tx - .update(postsTable) - .set({ content }) - .where(eq(postsTable.public_id, public_id)) + .insert(postsTable) + .values({ + public_id: target.public_id, + user_id: target.user_id, + content, + first_created_at: target.first_created_at, // 最初の作成日時を維持 + }) .returning(); const result = results[0]; @@ -89,17 +113,26 @@ const routes = postApp throw new HTTPException(500, { message: "Failed to update post", }); - return result; - }); - const response = { - post: await db.query.postsTable.findFirst({ - ...postWithUserQuery, - where: eq(postsTable.id, post.id), - }), - }; + await tx.insert(postLogsTable).values({ + id: result.id, + public_id: result.public_id, + user_id: result.user_id, + content: result.content, + created_at: result.created_at, + }); + }); - return c.json(updatePostResponseSchema.parse(response), 200); + const post = await db.query.postsTable.findFirst({ + ...postWithUserQuery, + where: eq(postsTable.public_id, public_id), + }); + if (!post) { + throw new HTTPException(500, { + message: "Failed to fetch updated post", + }); + } + return c.json({ post: transformPostWithUser(post) }, 200); }) .openapi(deletePostRoute, async (c) => { diff --git a/packages/backend_app/src/apps/user/index.test.ts b/packages/backend_app/src/apps/user/index.test.ts index 49be2303..acb3c8fd 100644 --- a/packages/backend_app/src/apps/user/index.test.ts +++ b/packages/backend_app/src/apps/user/index.test.ts @@ -49,6 +49,7 @@ describe("userApp", () => { describe("when supabase_uid is already registered", () => { beforeEach(async () => { await db.insert(usersTable).values({ + public_id: faker.string.uuid(), supabase_uid: supabaseUid, display_name: faker.person.fullName(), }); @@ -85,6 +86,7 @@ describe("userApp", () => { await db .insert(usersTable) .values({ + public_id: faker.string.uuid(), supabase_uid: supabaseUid, display_name: faker.person.fullName(), }) diff --git a/packages/backend_app/src/apps/user/index.ts b/packages/backend_app/src/apps/user/index.ts index f2ff610d..570a3de6 100644 --- a/packages/backend_app/src/apps/user/index.ts +++ b/packages/backend_app/src/apps/user/index.ts @@ -19,7 +19,7 @@ const routes = userApp const { display_name } = await c.req.valid("json"); await db .insert(usersTable) - .values({ supabase_uid, display_name }) + .values({ public_id: crypto.randomUUID(), supabase_uid, display_name }) .onConflictDoNothing({ target: usersTable.supabase_uid }); return c.json(null, 200); }) diff --git a/packages/backend_app/src/db/field.ts b/packages/backend_app/src/db/field.ts index 73495d1e..49b66cb1 100644 --- a/packages/backend_app/src/db/field.ts +++ b/packages/backend_app/src/db/field.ts @@ -8,8 +8,8 @@ export const userPublicFieldDefs = [ export const postPublicFieldDefs = [ "public_id", "content", + "first_created_at", "created_at", - "updated_at", ] satisfies (keyof typeof postsTable.$inferSelect)[]; const createFieldsFromDefs = ( diff --git a/packages/backend_app/src/db/schema.ts b/packages/backend_app/src/db/schema.ts index 414b10e9..1e262def 100644 --- a/packages/backend_app/src/db/schema.ts +++ b/packages/backend_app/src/db/schema.ts @@ -3,15 +3,11 @@ import { integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; const primaryKeys = () => ({ id: integer().primaryKey().generatedAlwaysAsIdentity(), // TODO: uuid v7 を検討 - public_id: uuid().unique().notNull().defaultRandom(), // uuid v4 + public_id: uuid().unique().notNull(), // uuid v4 }); const timestamps = { created_at: timestamp().defaultNow().notNull(), - updated_at: timestamp() - .defaultNow() - .notNull() - .$onUpdate(() => new Date()), }; export const usersTable = pgTable("users", { @@ -27,6 +23,7 @@ export const postsTable = pgTable("posts", { .notNull() .references(() => usersTable.id), content: text().notNull(), + first_created_at: timestamp().notNull(), // 最初の作成日時を保持(delete&insert方式のため) ...timestamps, }); @@ -36,3 +33,11 @@ export const postsRelations = relations(postsTable, ({ one }) => ({ references: [usersTable.id], }), })); + +export const postLogsTable = pgTable("post_logs", { + id: integer().primaryKey(), + public_id: uuid().notNull(), + user_id: integer().notNull(), + content: text().notNull(), + ...timestamps, +}); diff --git a/packages/backend_app/src/middlewares/user/index.test.ts b/packages/backend_app/src/middlewares/user/index.test.ts index d7567cb2..3b09bace 100644 --- a/packages/backend_app/src/middlewares/user/index.test.ts +++ b/packages/backend_app/src/middlewares/user/index.test.ts @@ -33,6 +33,7 @@ describe("userMiddleware", () => { describe("when user is found", () => { beforeEach(async () => { await db.insert(usersTable).values({ + public_id: faker.string.uuid(), supabase_uid: supabaseUid, display_name: faker.person.fullName(), }); diff --git a/packages/frontend_app/src/client/index.schemas.ts b/packages/frontend_app/src/client/index.schemas.ts index be7d7ddf..2e1df4f9 100644 --- a/packages/frontend_app/src/client/index.schemas.ts +++ b/packages/frontend_app/src/client/index.schemas.ts @@ -32,9 +32,9 @@ export interface Post { public_id: string; /** The content of the post */ content: string; - /** The date and time the post was created */ + /** The date and time the post was originally created */ created_at: string; - /** The date and time the post was updated */ + /** The date and time the post was created (or last updated in case of delete&insert) */ updated_at: string; } @@ -399,7 +399,7 @@ export type PutPostsPublicIdBody = { * The content of the post * @minLength 1 */ - content?: string; + content: string; }; export type PutPostsPublicId200PostAllOf = { diff --git a/packages/frontend_app/src/components/pages/post/components/PostCard/index.stories.tsx b/packages/frontend_app/src/components/pages/post/components/PostCard/index.stories.tsx index b90c0857..3b67e743 100644 --- a/packages/frontend_app/src/components/pages/post/components/PostCard/index.stories.tsx +++ b/packages/frontend_app/src/components/pages/post/components/PostCard/index.stories.tsx @@ -48,7 +48,7 @@ const createMockPost = ( public_id: faker.string.uuid(), content: postContent, created_at: "2025-01-01T00:00:00.000Z", - updated_at: "2025-01-01T00:00:00.000Z", + updated_at: null, user, ...overrides, }); diff --git a/packages/frontend_app/src/components/pages/post/components/PostCard/index.tsx b/packages/frontend_app/src/components/pages/post/components/PostCard/index.tsx index f43292b7..9ba409dc 100644 --- a/packages/frontend_app/src/components/pages/post/components/PostCard/index.tsx +++ b/packages/frontend_app/src/components/pages/post/components/PostCard/index.tsx @@ -24,7 +24,7 @@ type Post = { public_id: string; content: string; created_at: string; - updated_at: string; + updated_at: string | null; user: { public_id: string; display_name: string; @@ -42,7 +42,7 @@ export const PostCard = ({ post }: PostCardProps) => { const [isEditing, setIsEditing] = useState(false); const [editContent, setEditContent] = useState(post.content); - const isUpdated = post.updated_at !== post.created_at; + const isUpdated = post.updated_at !== null; const updatePostMutation = usePutPostsPublicId(); const deletePostMutation = useDeletePostsPublicId(); @@ -183,7 +183,7 @@ export const PostCard = ({ post }: PostCardProps) => { Created: {formatDate(post.created_at)} - {isUpdated && ( + {post.updated_at !== null && ( Updated: {formatDate(post.updated_at)}