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 && }
-