From b3cbc940f7f1dbb8beef4afb3edfc3259bec79ee Mon Sep 17 00:00:00 2001 From: Byeongju Park Date: Wed, 25 Mar 2026 21:53:00 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(posts):=20CodeLog=20v2=20=ED=8F=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EC=96=B4=EB=A5=BC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/api/comment.service.test.ts | 3 +- src/entities/post/api/post.action.test.ts | 63 +++++- src/entities/post/api/post.action.ts | 30 ++- src/entities/post/api/post.interface.ts | 8 +- src/entities/post/api/post.service.test.ts | 10 +- src/entities/post/api/post.service.ts | 49 ++--- src/features/post-interaction/model/index.ts | 2 +- .../post-interaction/model/post.schema.ts | 6 +- .../post-interaction/ui/post-dialog.tsx | 125 ++++++++---- .../post-interaction/ui/post-menu.tsx | 7 + .../post-list/api/post-list.action.ts | 1 - src/pages/home/ui/today-section.tsx | 75 +++++++- src/pages/today/ui/today.tsx | 26 ++- src/shared/lib/codelog.ts | 6 + src/shared/lib/index.ts | 1 + src/shared/lib/posthog/client.ts | 14 ++ src/shared/lib/query/post-list-query.ts | 3 - src/shared/types/database.types.ts | 15 +- src/shared/types/types.ts | 4 +- .../create-post/ui/create-post-form.tsx | 19 +- src/widgets/post-card/ui/post-card.tsx | 29 ++- .../post-card/ui/post-code-section.tsx | 2 +- src/widgets/post-card/ui/post-header.tsx | 25 +-- .../{lib => ui}/use-post-interaction.test.ts | 28 ++- .../{lib => ui}/use-post-interaction.ts | 87 ++++++--- src/widgets/sidebar/ui/welcome-card.tsx | 2 +- .../20260325100000_codelog_v2_core.sql | 180 ++++++++++++++++++ 27 files changed, 667 insertions(+), 153 deletions(-) create mode 100644 src/shared/lib/codelog.ts rename src/widgets/post-card/{lib => ui}/use-post-interaction.test.ts (89%) rename src/widgets/post-card/{lib => ui}/use-post-interaction.ts (54%) create mode 100644 supabase/migrations/20260325100000_codelog_v2_core.sql diff --git a/src/entities/comment/api/comment.service.test.ts b/src/entities/comment/api/comment.service.test.ts index 5ea9047..dbfc97e 100644 --- a/src/entities/comment/api/comment.service.test.ts +++ b/src/entities/comment/api/comment.service.test.ts @@ -195,8 +195,10 @@ function createFakeAdapter() { id: 10, user_id: "user-1", content: "post", + description: "post summary", code: null, language: null, + authoring_mode: "hand_written", created_at: "2026-03-12T00:00:00.000Z", updated_at: null, deleted_at: null, @@ -204,7 +206,6 @@ function createFakeAdapter() { like_count: 0, bookmark_count: 0, view_count: 0, - is_review_enabled: true, }, ], ); diff --git a/src/entities/post/api/post.action.test.ts b/src/entities/post/api/post.action.test.ts index 45f70b3..17b502f 100644 --- a/src/entities/post/api/post.action.test.ts +++ b/src/entities/post/api/post.action.test.ts @@ -6,6 +6,7 @@ const { getCurrentUserAuthMock, getPostByIdMock, getReviewCommentsCountMock, + hasUserPostedOnLocalDayMock, revalidatePathMock, updatePostMock, } = vi.hoisted(() => ({ @@ -14,6 +15,7 @@ const { createPostMock: vi.fn(), getPostByIdMock: vi.fn(), getReviewCommentsCountMock: vi.fn(), + hasUserPostedOnLocalDayMock: vi.fn(), updatePostMock: vi.fn(), deletePostMock: vi.fn(), })); @@ -30,6 +32,7 @@ vi.mock("@/entities/post/api/post.service", () => ({ createPost: createPostMock, getPostById: getPostByIdMock, getReviewCommentsCount: getReviewCommentsCountMock, + hasUserPostedOnLocalDay: hasUserPostedOnLocalDayMock, updatePost: updatePostMock, deletePost: deletePostMock, })); @@ -39,6 +42,10 @@ import { createPostAction, updatePostAction } from "./post.action"; describe("post.action", () => { beforeEach(() => { vi.clearAllMocks(); + hasUserPostedOnLocalDayMock.mockResolvedValue({ + data: false, + error: null, + }); }); it("createPostAction은 서버 세션 유저를 author로 강제한다", async () => { @@ -64,13 +71,27 @@ describe("post.action", () => { bio: "", }, content: "content", + description: "description", code: "const a = 1;", language: "typescript", + authoring_mode: "hand_written", tags: ["ts"], - is_review_enabled: true, + localDayContext: { + dayKey: "2026-03-25", + dayStartAt: "2026-03-24T15:00:00.000Z", + dayEndAt: "2026-03-25T14:59:59.999Z", + timezoneOffsetMinutes: -540, + }, }); expect(result.error).toBeNull(); + expect(hasUserPostedOnLocalDayMock).toHaveBeenCalledWith({ + dayKey: "2026-03-25", + dayStartAt: "2026-03-24T15:00:00.000Z", + dayEndAt: "2026-03-25T14:59:59.999Z", + timezoneOffsetMinutes: -540, + userId: "server-user", + }); expect(createPostMock).toHaveBeenCalledWith( expect.objectContaining({ author: user, @@ -79,6 +100,42 @@ describe("post.action", () => { expect(revalidatePathMock).toHaveBeenCalledWith("/home"); }); + it("createPostAction은 오늘 이미 작성한 경우 생성을 차단한다", async () => { + getCurrentUserAuthMock.mockResolvedValue({ + id: "server-user", + username: "server_user", + }); + hasUserPostedOnLocalDayMock.mockResolvedValue({ + data: true, + error: null, + }); + + const result = await createPostAction({ + author: { + id: "client-user", + username: "client_user", + nickname: "Client User", + avatar: "", + bio: "", + }, + content: "content", + description: "description", + code: null, + language: null, + authoring_mode: "hand_written", + tags: [], + localDayContext: { + dayKey: "2026-03-25", + dayStartAt: "2026-03-24T15:00:00.000Z", + dayEndAt: "2026-03-25T14:59:59.999Z", + timezoneOffsetMinutes: -540, + }, + }); + + expect(result).toEqual({ error: "오늘은 이미 글을 작성했습니다." }); + expect(createPostMock).not.toHaveBeenCalled(); + }); + it("updatePostAction은 타인 포스트 수정을 차단한다", async () => { getCurrentUserAuthMock.mockResolvedValue({ id: "me", @@ -99,7 +156,7 @@ describe("post.action", () => { expect(updatePostMock).not.toHaveBeenCalled(); }); - it("리뷰 댓글이 존재하면 코드 수정을 차단한다", async () => { + it("인라인 코멘트가 존재하면 코드 수정을 차단한다", async () => { getCurrentUserAuthMock.mockResolvedValue({ id: "me", username: "me", @@ -120,7 +177,7 @@ describe("post.action", () => { const result = await updatePostAction(1, { code: "changed" }); expect(result).toEqual({ - error: "코드 리뷰가 존재하는 포스트는 코드를 수정할 수 없습니다.", + error: "인라인 코멘트가 존재하는 포스트는 코드를 수정할 수 없습니다.", }); expect(updatePostMock).not.toHaveBeenCalled(); }); diff --git a/src/entities/post/api/post.action.ts b/src/entities/post/api/post.action.ts index 851b8d9..a9b017b 100644 --- a/src/entities/post/api/post.action.ts +++ b/src/entities/post/api/post.action.ts @@ -8,19 +8,43 @@ import { deletePost, getPostById, getReviewCommentsCount, + hasUserPostedOnLocalDay, updatePost, } from "@/entities/post/api/post.service"; +import { type LocalDayContext } from "@/shared/lib/date"; import { getCurrentUserAuth } from "@/shared/lib/supabase/current-user"; -async function createPostAction(data: CreatePostDTO) { +type CreatePostActionInput = CreatePostDTO & { + localDayContext: LocalDayContext; +}; + +async function createPostAction(data: CreatePostActionInput) { const user = await getCurrentUserAuth(); if (!user) { return { error: "로그인이 필요합니다." }; } + const { localDayContext, ...postData } = data; + const { data: hasPostedToday, error: hasPostedError } = + await hasUserPostedOnLocalDay({ + ...localDayContext, + userId: user.id, + }); + + if (hasPostedError) { + console.error(hasPostedError); + return { + error: hasPostedError.message || "오늘 작성 여부 확인에 실패했습니다.", + }; + } + + if (hasPostedToday) { + return { error: "오늘은 이미 글을 작성했습니다." }; + } + // 데이터의 author는 클라이언트에서 오므로, 서버의 신뢰할 수 있는 user 정보로 덮어씌웁니다. - const secureData = { ...data, author: user }; + const secureData = { ...postData, author: user }; const { data: newPost, error } = await createPost(secureData); @@ -61,7 +85,7 @@ async function updatePostAction(id: number, data: Partial) { if (count && count > 0) { return { - error: "코드 리뷰가 존재하는 포스트는 코드를 수정할 수 없습니다.", + error: "인라인 코멘트가 존재하는 포스트는 코드를 수정할 수 없습니다.", }; } } diff --git a/src/entities/post/api/post.interface.ts b/src/entities/post/api/post.interface.ts index ea5307e..2df4c9a 100644 --- a/src/entities/post/api/post.interface.ts +++ b/src/entities/post/api/post.interface.ts @@ -5,5 +5,11 @@ import { Post } from "@/shared/types/types"; */ export type CreatePostDTO = Pick< Post, - "author" | "content" | "code" | "language" | "tags" | "is_review_enabled" + | "author" + | "content" + | "description" + | "code" + | "language" + | "authoring_mode" + | "tags" >; diff --git a/src/entities/post/api/post.service.test.ts b/src/entities/post/api/post.service.test.ts index 64702d9..7d6addd 100644 --- a/src/entities/post/api/post.service.test.ts +++ b/src/entities/post/api/post.service.test.ts @@ -40,7 +40,6 @@ describe("post.service", () => { }); await getPosts({ - isReviewEnabled: true, authorId: "author-1", likedByUserId: "liked-user", bookmarkedByUserId: "bookmark-user", @@ -59,11 +58,10 @@ describe("post.service", () => { { column: "post_likes.user_id", value: "liked-user" }, { column: "bookmarks.user_id", value: "bookmark-user" }, { column: "filter_tags.tags.name", value: "typescript" }, - { column: "is_review_enabled", value: true }, { column: "user_id", value: "author-1" }, ]), range: { from: 0, to: 2 }, - or: "content.ilike.%a b\\_\\% test%,code.ilike.%a b\\_\\% test%", + or: "description.ilike.%a b\\_\\% test%,content.ilike.%a b\\_\\% test%,code.ilike.%a b\\_\\% test%", }), ); }); @@ -100,9 +98,10 @@ describe("post.service", () => { const result = await updatePost(99, { content: "updated", + description: "summary", code: "const x = 1;", language: "typescript", - is_review_enabled: true, + authoring_mode: "ai_assisted", tags: ["ts", "vitest"], }); @@ -110,9 +109,10 @@ describe("post.service", () => { p_post_id: 99, post_data: { content: "updated", + description: "summary", code: "const x = 1;", language: "typescript", - is_review_enabled: true, + authoring_mode: "ai_assisted", }, tags: ["ts", "vitest"], }); diff --git a/src/entities/post/api/post.service.ts b/src/entities/post/api/post.service.ts index 84c588c..63825d3 100644 --- a/src/entities/post/api/post.service.ts +++ b/src/entities/post/api/post.service.ts @@ -4,7 +4,7 @@ import { isValidLocalDayContext, type LocalDayContext, } from "@/shared/lib/date"; -import { Database, Tables } from "@/shared/types/database.types"; +import { Tables } from "@/shared/types/database.types"; import { Post } from "@/shared/types/types"; import { CreatePostDTO } from "./post.interface"; @@ -24,6 +24,15 @@ type LocalDayListQueryOptions = LocalDayContext & { limit?: number; }; +type PostMutationPayload = { + content: string; + description: string; + code: string | null; + language: string | null; + user_id?: string; + authoring_mode: Post["authoring_mode"]; +}; + function sanitizeKeywordForOrFilter(rawKeyword: string): string { return rawKeyword.replace(/[(),]/g, " ").replace(/\s+/g, " ").trim(); } @@ -87,14 +96,14 @@ export async function createPost( data: CreatePostDTO, ): Promise<{ data: Post | null; error: Error | null }> { const db = getDatabaseAdapter(); - const postData: Database["public"]["Functions"]["create_post_with_tags"]["Args"]["post_data"] = - { - content: data.content, - code: data.code, - language: data.language, - user_id: data.author.id, - is_review_enabled: data.is_review_enabled, - }; + const postData: PostMutationPayload = { + content: data.content, + description: data.description, + code: data.code, + language: data.language, + user_id: data.author.id, + authoring_mode: data.authoring_mode, + }; const { data: insertedPost, error } = await db.rpc>( "create_post_with_tags", @@ -118,7 +127,6 @@ export async function createPost( } export async function getPosts({ - isReviewEnabled = false, authorId, likedByUserId, bookmarkedByUserId, @@ -127,7 +135,6 @@ export async function getPosts({ offset, limit, }: { - isReviewEnabled?: boolean; authorId?: string; likedByUserId?: string; bookmarkedByUserId?: string; @@ -167,10 +174,6 @@ export async function getPosts({ filters.push({ column: "filter_tags.tags.name", value: tag }); } - if (isReviewEnabled) { - filters.push({ column: "is_review_enabled", value: true }); - } - if (authorId) { filters.push({ column: "user_id", value: authorId }); } @@ -187,7 +190,7 @@ export async function getPosts({ select: selectString, filters, or: escapedKeyword - ? `content.ilike.%${escapedKeyword}%,code.ilike.%${escapedKeyword}%` + ? `description.ilike.%${escapedKeyword}%,content.ilike.%${escapedKeyword}%,code.ilike.%${escapedKeyword}%` : undefined, orderBy: { column: "created_at", ascending: false }, range: @@ -435,13 +438,13 @@ export async function updatePost( ): Promise<{ data: Post | null; error: Error | null }> { const db = getDatabaseAdapter(); const { tags, ...postFields } = data; - const postData: Database["public"]["Functions"]["update_post_with_tags"]["Args"]["post_data"] = - { - content: postFields.content, - code: postFields.code, - language: postFields.language, - is_review_enabled: postFields.is_review_enabled, - }; + const postData: Partial = { + content: postFields.content, + description: postFields.description, + code: postFields.code, + language: postFields.language, + authoring_mode: postFields.authoring_mode, + }; const { error } = await db.rpc("update_post_with_tags", { p_post_id: id, diff --git a/src/features/post-interaction/model/index.ts b/src/features/post-interaction/model/index.ts index 2f28d1b..b92733d 100644 --- a/src/features/post-interaction/model/index.ts +++ b/src/features/post-interaction/model/index.ts @@ -1 +1 @@ -export { type PostFormData,postSchema } from "./post.schema"; +export { type PostFormData, postSchema } from "./post.schema"; diff --git a/src/features/post-interaction/model/post.schema.ts b/src/features/post-interaction/model/post.schema.ts index 57be13e..bb724b7 100644 --- a/src/features/post-interaction/model/post.schema.ts +++ b/src/features/post-interaction/model/post.schema.ts @@ -1,6 +1,11 @@ import { z } from "zod"; export const postSchema = z.object({ + description: z + .string() + .trim() + .min(1, "짧은 설명을 입력해주세요.") + .max(200, "짧은 설명은 200자 이하여야 합니다."), content: z .string() .trim() @@ -8,7 +13,6 @@ export const postSchema = z.object({ .max(5000, "게시글은 5000자 이하여야 합니다."), code: z.string().nullable(), language: z.string().nullable(), - isReviewEnabled: z.boolean(), }); export type PostFormData = z.infer; diff --git a/src/features/post-interaction/ui/post-dialog.tsx b/src/features/post-interaction/ui/post-dialog.tsx index 1c27ee4..831ba78 100644 --- a/src/features/post-interaction/ui/post-dialog.tsx +++ b/src/features/post-interaction/ui/post-dialog.tsx @@ -1,21 +1,24 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useForm, useWatch } from "react-hook-form"; import dynamic from "next/dynamic"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Code2, Info, Loader, Plus, Send } from "lucide-react"; +import { Code2, Loader, Plus, Send } from "lucide-react"; import { createPostAction, updatePostAction } from "@/entities/post"; import { useAuth, UserAvatar } from "@/entities/user"; +import { getCurrentLocalDayContext } from "@/shared/lib/date"; import { handleAction } from "@/shared/lib/handle-action"; -import { captureEvent } from "@/shared/lib/posthog"; +import { + captureEvent, + getTodayExperimentProperties, +} from "@/shared/lib/posthog"; import { POST_LIST_QUERY_KEY } from "@/shared/lib/query/post-list-query"; import { Post } from "@/shared/types/types"; import { Button } from "@/shared/ui/button"; -import { Checkbox } from "@/shared/ui/checkbox"; import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -30,25 +33,36 @@ import { Input } from "@/shared/ui/input"; import { Label } from "@/shared/ui/label"; import { TagList } from "@/shared/ui/tag-list"; import { Textarea } from "@/shared/ui/textarea"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -import { type PostFormData,postSchema } from "../model"; +import { type PostFormData, postSchema } from "../model"; interface PostDialogProps { isOpen: boolean; handleClose: () => void; post?: Post; + openedAtMs?: number | null; source?: string; } +function getElapsedTimeMs(startedAt: number | null) { + if (startedAt === null) { + return null; + } + + return Math.round(performance.now() - startedAt); +} + export default function PostDialog({ isOpen, handleClose, + openedAtMs = null, post, source = "create_post_widget", }: PostDialogProps) { const { user } = useAuth(); const queryClient = useQueryClient(); + const localDayContext = useMemo(() => getCurrentLocalDayContext(), []); + const [hasSubmitted, setHasSubmitted] = useState(false); const { register, @@ -60,23 +74,21 @@ export default function PostDialog({ } = useForm({ resolver: zodResolver(postSchema), defaultValues: { + description: post?.description || "", content: post?.content || "", code: post?.code || null, language: post?.language || "text", - isReviewEnabled: post?.is_review_enabled || false, }, }); - const [snippetMode, setSnippetMode] = useState(!!post?.code); + const [snippetMode, setSnippetMode] = useState(Boolean(post?.code)); const [tags, setTags] = useState(post?.tags || []); const [tagInput, setTagInput] = useState(""); + const descriptionValue = useWatch({ control, name: "description" }); + const contentValue = useWatch({ control, name: "content" }); const codeValue = useWatch({ control, name: "code" }); const languageValue = useWatch({ control, name: "language" }); - const isReviewEnabledValue = useWatch({ - control, - name: "isReviewEnabled", - }); const toggleSnippetMode = () => { captureEvent("post_snippet_mode_toggled", { @@ -121,17 +133,19 @@ export default function PostDialog({ const savePostMutation = useMutation({ mutationFn: async (commonData: { + description: string; content: string; code: string | null; language: string | null; tags: string[]; - is_review_enabled: boolean; + authoring_mode: Post["authoring_mode"]; }) => { const action = post ? updatePostAction(post.id, commonData) : createPostAction({ author: user!, ...commonData, + localDayContext, }); return handleAction(action, { @@ -147,8 +161,14 @@ export default function PostDialog({ } captureEvent(post ? "post_updated" : "post_created", { + authoring_mode: commonData.authoring_mode, + content_length: commonData.content.length, + description_length: commonData.description.length, has_code: Boolean(commonData.code), tag_count: commonData.tags.length, + time_to_submit_ms: getElapsedTimeMs(openedAtMs), + source, + ...getTodayExperimentProperties(), }); handleClose(); @@ -162,33 +182,83 @@ export default function PostDialog({ const onSubmit = async (data: PostFormData) => { if (!user) return; + setHasSubmitted(true); + const timeToSubmitMs = getElapsedTimeMs(openedAtMs); + const commonData = { + description: data.description, content: data.content, code: snippetMode ? data.code : null, language: snippetMode ? data.language : null, + authoring_mode: post?.authoring_mode ?? "hand_written", tags, - is_review_enabled: data.isReviewEnabled, }; captureEvent(post ? "post_update_submitted" : "post_create_submitted", { + authoring_mode: commonData.authoring_mode, + content_length: commonData.content.length, + description_length: commonData.description.length, has_code: Boolean(commonData.code), tag_count: tags.length, - review_enabled: commonData.is_review_enabled, + time_to_submit_ms: timeToSubmitMs, source, + ...getTodayExperimentProperties(), }); await savePostMutation.mutateAsync(commonData); }; + const handleDialogChange = (nextOpen: boolean) => { + if (!nextOpen && !hasSubmitted) { + captureEvent("post_dialog_closed", { + had_input: Boolean( + descriptionValue?.trim() || + contentValue?.trim() || + codeValue?.trim() || + tagInput.trim() || + tags.length > 0, + ), + source, + ...getTodayExperimentProperties(), + }); + } + + if (!nextOpen) { + handleClose(); + } + }; + return ( - + {post ? "게시글 수정" : "새 게시글 작성"}
{user && }
-