From 835cc918860fee5e86a80d89b212cd3a46478f38 Mon Sep 17 00:00:00 2001 From: dev-minsoo Date: Fri, 13 Feb 2026 16:06:41 +0900 Subject: [PATCH 1/2] fix(backend): count participants instead of votes for multi-select polls --- backend/app/services/vote.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/services/vote.py b/backend/app/services/vote.py index 07e0a05..dbb9d67 100644 --- a/backend/app/services/vote.py +++ b/backend/app/services/vote.py @@ -26,5 +26,5 @@ async def cast_vote(room_uuid: str, options: list[str], fingerprint: str, ip: st if room_ttl > 0: await redis.setex(voted_key, room_ttl, "1") - # 인기순 인덱스 업데이트 (투표 수 기준) - await update_room_total_votes(room_uuid, len(options)) + # 인기순 인덱스 업데이트 (참여자 수 기준) + await update_room_total_votes(room_uuid, 1) From 7ae1decb050e79fecc17441653a24fcaffe6e666 Mon Sep 17 00:00:00 2001 From: dev-minsoo Date: Fri, 13 Feb 2026 17:11:36 +0900 Subject: [PATCH 2/2] feat: retain poll results for 7 days after expiry (creator-only access) - Extend Redis TTL by 7 days (RESULT_RETENTION_TTL) so data persists after poll closes - Use expires_at for deadline logic, separate from Redis TTL - Block voting on expired polls (410 Gone) - Require share_token for result access after expiry - Filter expired polls from public /polls listing - Keep expired polls in /my-polls with "Closed" badge - Add i18n messages for poll closed state (ko/en) --- backend/app/routers/rooms.py | 18 ++++++++- backend/app/services/room.py | 27 +++++++++++-- frontend/app/my-polls/my-polls.tsx | 19 ++++----- frontend/app/vote/[uuid]/vote-client.tsx | 49 +++++++++++++++++++++++- frontend/lib/api.ts | 10 +++-- frontend/lib/i18n.ts | 12 ++++++ 6 files changed, 115 insertions(+), 20 deletions(-) diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py index 728be08..b20b5e1 100644 --- a/backend/app/routers/rooms.py +++ b/backend/app/routers/rooms.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, HTTPException, Query, Request from app.models.schemas import RoomCreate, VoteRequest, PasswordVerifyRequest, SortOrder, RoomListResponse, CommentCreate, Comment -from app.services.room import create_room, get_room, get_vote_results, get_room_list +from app.services.room import create_room, get_room, get_vote_results, get_room_list, is_room_expired from app.services.vote import has_voted, cast_vote from app.services.comment import create_comment, get_comments from app.utils.security import verify_password @@ -54,6 +54,7 @@ async def get_room_info(room_uuid: str): response.pop("password_hash", None) # Do not expose share_token on GET room response.pop("share_token", None) + response["is_expired"] = is_room_expired(room) return response @@ -84,6 +85,9 @@ async def vote(room_uuid: str, vote_request: VoteRequest, request: Request): if not room: raise HTTPException(status_code=404, detail="투표방을 찾을 수 없습니다") + if is_room_expired(room): + raise HTTPException(status_code=410, detail="투표가 마감되었습니다") + # 모든 선택한 옵션이 유효한지 확인 for option in vote_request.options: if option not in room["options"]: @@ -104,12 +108,22 @@ async def vote(room_uuid: str, vote_request: VoteRequest, request: Request): @router.get("/{room_uuid}/results") -async def get_results(room_uuid: str, request: Request, fingerprint: str | None = Query(None, description="Client fingerprint")): +async def get_results( + room_uuid: str, + request: Request, + fingerprint: str | None = Query(None, description="Client fingerprint"), + share_token: str | None = Query(None, description="Share token for creator access"), +): """투표 결과 조회""" room = await get_room(room_uuid) if not room: raise HTTPException(status_code=404, detail="투표방을 찾을 수 없습니다") + # 만료 후에는 share_token이 있어야 결과 조회 가능 + if is_room_expired(room): + if not share_token or share_token != room.get("share_token"): + raise HTTPException(status_code=410, detail="투표가 마감되었습니다") + results = await get_vote_results(room_uuid) has_voted_flag: bool | None = None if fingerprint: diff --git a/backend/app/services/room.py b/backend/app/services/room.py index 4559db1..c50e4be 100644 --- a/backend/app/services/room.py +++ b/backend/app/services/room.py @@ -5,6 +5,9 @@ from app.database import get_redis from app.utils.security import hash_password +# 투표 결과 보관 기간: 만료 후 7일간 생성자가 결과 확인 가능 +RESULT_RETENTION_TTL = 604800 # 7 days in seconds + async def create_room( title: str, @@ -43,11 +46,12 @@ async def create_room( from secrets import token_urlsafe room_data["share_token"] = token_urlsafe(32) - await redis.setex(f"room:{room_uuid}", ttl, json.dumps(room_data)) + redis_ttl = ttl + RESULT_RETENTION_TTL + await redis.setex(f"room:{room_uuid}", redis_ttl, json.dumps(room_data)) for option in options: await redis.hset(f"votes:{room_uuid}", option, 0) - await redis.expire(f"votes:{room_uuid}", ttl) + await redis.expire(f"votes:{room_uuid}", redis_ttl) # 인덱스 추가: 최신순 await redis.zadd("rooms:list", {room_uuid: timestamp}) @@ -74,6 +78,15 @@ async def get_room(room_uuid: str) -> dict | None: return None +def is_room_expired(room: dict) -> bool: + """expires_at 기준으로 투표 마감 여부 확인""" + expires_at_str = room.get("expires_at") + if not expires_at_str: + return False + expires_at = datetime.fromisoformat(expires_at_str) + return expires_at <= datetime.now(timezone.utc) + + async def get_vote_results(room_uuid: str) -> dict: """투표 결과 조회""" redis = get_redis() @@ -115,17 +128,23 @@ async def get_room_list( # 유효한 방만 필터링 (만료되지 않은 방) valid_rooms = [] expired_uuids = [] + now = datetime.now(timezone.utc) for room_uuid in room_uuids: room = await get_room(room_uuid) if room: - # 비공개 방도 목록에 포함 (is_private 필터링 제거) + # expires_at이 지난 방은 공개 목록에서 제외 + expires_at_str = room.get("expires_at") + if expires_at_str: + expires_at = datetime.fromisoformat(expires_at_str) + if expires_at <= now: + continue # 검색 필터 (제목만) if search and search.lower() not in room.get("title", "").lower(): continue valid_rooms.append(room) else: - # 만료된 방은 인덱스에서 제거 예정 + # Redis에서 완전 삭제된 방은 인덱스에서 제거 예정 expired_uuids.append(room_uuid) # 만료된 방 인덱스 정리 (비동기로 처리) diff --git a/frontend/app/my-polls/my-polls.tsx b/frontend/app/my-polls/my-polls.tsx index a95fc3b..528e5b5 100644 --- a/frontend/app/my-polls/my-polls.tsx +++ b/frontend/app/my-polls/my-polls.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; -import { BarChart2, Clock, Lock, CheckSquare, Trash2 } from "lucide-react"; +import { BarChart2, Clock, Lock, CheckSquare, Trash2, XCircle } from "lucide-react"; import { api, APIError, type MyPollRecord } from "@/lib/api"; import { getMessages, getLocaleFromCookie } from "@/lib/i18n"; @@ -33,13 +33,8 @@ export default function MyPolls({ localeCookie }: { localeCookie?: string | null const [items, setItems] = useState([]); useEffect(() => { - // Load from storage, cleanup expired entries - const now = Date.now(); - const loaded = parseStored().filter((it) => { - if (!it.expires_at) return true; - const t = Date.parse(it.expires_at); - return !isNaN(t) ? t > now : true; - }); + // Load from storage (keep expired items for creator result access) + const loaded = parseStored(); // sort newest first by created_at (fallback to keep order) loaded.sort((a, b) => { @@ -113,6 +108,12 @@ export default function MyPolls({ localeCookie }: { localeCookie?: string | null {/* Badges - fixed height */}
+ {room.expires_at && Date.parse(room.expires_at) <= Date.now() && ( + + + {t.closedBadge} + + )} {room.has_password && ( @@ -167,7 +168,7 @@ export default function MyPolls({ localeCookie }: { localeCookie?: string | null href={room.share_token ? `/vote/${room.uuid}?share_token=${encodeURIComponent(room.share_token)}` : `/vote/${room.uuid}`} className="flex items-center gap-1 text-sm font-medium text-emerald-600 transition-colors hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300" > - {t.viewPoll} + {room.expires_at && Date.parse(room.expires_at) <= Date.now() ? t.viewResults : t.viewPoll}
diff --git a/frontend/app/vote/[uuid]/vote-client.tsx b/frontend/app/vote/[uuid]/vote-client.tsx index 8214ee6..235f750 100644 --- a/frontend/app/vote/[uuid]/vote-client.tsx +++ b/frontend/app/vote/[uuid]/vote-client.tsx @@ -20,7 +20,7 @@ interface PageProps { params: Promise<{ uuid: string }>; } -type ViewState = 'loading' | 'password' | 'voting' | 'voted' | 'error'; +type ViewState = 'loading' | 'password' | 'voting' | 'voted' | 'error' | 'closed'; export function VoteClient({ params }: PageProps) { const { uuid } = use(params); @@ -67,6 +67,25 @@ export function VoteClient({ params }: PageProps) { const params = new URLSearchParams(window.location.search); const shareToken = params.get('share_token'); + // 만료된 투표 처리 + if (data.is_expired) { + if (!shareToken) { + // 일반 방문자 → 마감 메시지만 표시 + setState('closed'); + return; + } + // 생성자 (share_token 있음) → 결과 조회 + try { + const fingerprint = getFingerprint(); + const resultsData = await api.getResults(uuid, fingerprint, shareToken); + setResults(resultsData); + } catch (err) { + console.error('Failed to load results for expired poll:', err); + } + setState('voted'); + return; + } + if (data.has_password) { // attempt share_token bypass if (shareToken) { @@ -340,6 +359,27 @@ export function VoteClient({ params }: PageProps) { ); } + // Closed (expired) state - no share_token + if (state === "closed" && room) { + return ( +
+ +
+ + + {t.closedBadge} + +

{t.pollClosed}

+

{t.pollClosedDescription}

+ +
+
+
+ ); + } + // Password entry state if (state === "password" && room) { return ( @@ -421,7 +461,12 @@ export function VoteClient({ params }: PageProps) { {/* Emerald Gradient Header */}
- {room.has_password && ( + {room.is_expired && ( + + {t.closedBadge} + + )} + {room.has_password && !room.is_expired && ( 🔒 {t.privateBadge} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 32d2201..1ffdc50 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -36,6 +36,7 @@ export interface VoteRoom { tags?: string[]; allow_multiple?: boolean; expires_at?: string | null; + is_expired?: boolean; } export interface VoteResults { @@ -199,9 +200,12 @@ export const api = { }), // Get current results - getResults: (uuid: string, fingerprint?: string) => { - const params = fingerprint ? `?fingerprint=${encodeURIComponent(fingerprint)}` : ''; - return fetchAPI(`/rooms/${uuid}/results${params}`); + getResults: (uuid: string, fingerprint?: string, share_token?: string) => { + const searchParams = new URLSearchParams(); + if (fingerprint) searchParams.set('fingerprint', fingerprint); + if (share_token) searchParams.set('share_token', share_token); + const qs = searchParams.toString(); + return fetchAPI(`/rooms/${uuid}/results${qs ? `?${qs}` : ''}`); }, // Create a comment diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts index 6d260db..a1d130a 100644 --- a/frontend/lib/i18n.ts +++ b/frontend/lib/i18n.ts @@ -182,6 +182,8 @@ export const messages = { myPollsTotal: (count: number) => `총 ${count}개의 투표`, myPollsEmpty: "저장된 투표가 없습니다.", deletePoll: "삭제", + closedBadge: "마감됨", + viewResults: "결과 보기", }, vote: { loading: "투표방 정보를 불러오는 중...", @@ -240,6 +242,10 @@ export const messages = { justNow: "방금 전", minutesAgo: (n: number) => `${n}분 전`, hoursAgo: (n: number) => `${n}시간 전`, + pollClosed: "투표가 마감되었습니다", + pollClosedDescription: "이 투표는 마감되어 결과를 확인할 수 없습니다.", + closedBadge: "마감됨", + expired: "만료", errors: { notFound: "투표방을 찾을 수 없습니다", loadFailed: "투표방 불러오기에 실패했습니다", @@ -420,6 +426,8 @@ export const messages = { myPollsTotal: (count: number) => `${count} poll${count !== 1 ? "s" : ""}`, myPollsEmpty: "No saved polls.", deletePoll: "Remove", + closedBadge: "Closed", + viewResults: "View results", }, vote: { loading: "Loading vote room...", @@ -478,6 +486,10 @@ export const messages = { justNow: "just now", minutesAgo: (n: number) => `${n}m ago`, hoursAgo: (n: number) => `${n}h ago`, + pollClosed: "This poll has closed", + pollClosedDescription: "This poll has ended and results are no longer available.", + closedBadge: "Closed", + expired: "Expired", errors: { notFound: "Vote room not found", loadFailed: "Failed to load vote room",