Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions backend/app/routers/rooms.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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"]:
Expand All @@ -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:
Expand Down
27 changes: 23 additions & 4 deletions backend/app/services/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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})
Expand All @@ -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()
Expand Down Expand Up @@ -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)

# 만료된 방 인덱스 정리 (비동기로 처리)
Expand Down
4 changes: 2 additions & 2 deletions backend/app/services/vote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
19 changes: 10 additions & 9 deletions frontend/app/my-polls/my-polls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -33,13 +33,8 @@ export default function MyPolls({ localeCookie }: { localeCookie?: string | null
const [items, setItems] = useState<MyPollRecord[]>([]);

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) => {
Expand Down Expand Up @@ -113,6 +108,12 @@ export default function MyPolls({ localeCookie }: { localeCookie?: string | null

{/* Badges - fixed height */}
<div className="mb-3 flex min-h-[28px] flex-wrap items-start gap-2">
{room.expires_at && Date.parse(room.expires_at) <= Date.now() && (
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-1 text-xs font-medium text-red-600 dark:bg-red-900/30 dark:text-red-400">
<XCircle size={12} />
{t.closedBadge}
</span>
)}
{room.has_password && (
<span className="inline-flex items-center gap-1 rounded-full bg-orange-100 px-2.5 py-1 text-xs font-medium text-orange-600 dark:bg-orange-900/30 dark:text-orange-400">
<Lock size={12} />
Expand Down Expand Up @@ -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}
<span className="transition-transform group-hover:translate-x-0.5">→</span>
</Link>
</div>
Expand Down
49 changes: 47 additions & 2 deletions frontend/app/vote/[uuid]/vote-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
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);
Expand All @@ -38,7 +38,7 @@
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
const [voteError, setVoteError] = useState('');

const [copySuccess, setCopySuccess] = useState(false);

Check warning on line 41 in frontend/app/vote/[uuid]/vote-client.tsx

View workflow job for this annotation

GitHub Actions / test

'copySuccess' is assigned a value but never used
const [shouldConnectWs, setShouldConnectWs] = useState(false);

const [comments, setComments] = useState<Comment[]>([]);
Expand Down Expand Up @@ -67,12 +67,31 @@
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) {
try {
await api.verifyPassword(uuid, undefined, shareToken);
} catch (err) {

Check warning on line 94 in frontend/app/vote/[uuid]/vote-client.tsx

View workflow job for this annotation

GitHub Actions / test

'err' is defined but never used
setState('password');
return;
}
Expand Down Expand Up @@ -119,7 +138,7 @@
};

loadRoom();
}, [uuid]);

Check warning on line 141 in frontend/app/vote/[uuid]/vote-client.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useEffect has missing dependencies: 't.errors.loadFailed' and 't.errors.notFound'. Either include them or remove the dependency array

// WebSocket connection for real-time updates
useEffect(() => {
Expand Down Expand Up @@ -340,6 +359,27 @@
);
}

// Closed (expired) state - no share_token
if (state === "closed" && room) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
<Navbar />
<div className="mx-auto flex max-w-3xl items-center justify-center px-4 py-16">
<Card className="w-full p-8 text-center">
<Badge variant="secondary" className="mb-3">
{t.closedBadge}
</Badge>
<h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100 mb-3">{t.pollClosed}</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-300 mb-6">{t.pollClosedDescription}</p>
<Button asChild variant="secondary">
<Link href="/polls">{t.backToList}</Link>
</Button>
</Card>
</div>
</div>
);
}

// Password entry state
if (state === "password" && room) {
return (
Expand Down Expand Up @@ -421,7 +461,12 @@

{/* Emerald Gradient Header */}
<div className="bg-gradient-to-r from-emerald-600 to-emerald-500 text-white px-6 py-8 rounded-t-2xl relative">
{room.has_password && (
{room.is_expired && (
<Badge className="bg-red-500/80 text-white border-0 mb-3 hover:bg-red-500/90">
{t.closedBadge}
</Badge>
)}
{room.has_password && !room.is_expired && (
<Badge className="bg-white/20 text-white border-0 mb-3 hover:bg-white/30">
🔒 {t.privateBadge}
</Badge>
Expand Down
10 changes: 7 additions & 3 deletions frontend/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface VoteRoom {
tags?: string[];
allow_multiple?: boolean;
expires_at?: string | null;
is_expired?: boolean;
}

export interface VoteResults {
Expand Down Expand Up @@ -199,9 +200,12 @@ export const api = {
}),

// Get current results
getResults: (uuid: string, fingerprint?: string) => {
const params = fingerprint ? `?fingerprint=${encodeURIComponent(fingerprint)}` : '';
return fetchAPI<VoteResults>(`/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<VoteResults>(`/rooms/${uuid}/results${qs ? `?${qs}` : ''}`);
},

// Create a comment
Expand Down
12 changes: 12 additions & 0 deletions frontend/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ export const messages = {
myPollsTotal: (count: number) => `총 ${count}개의 투표`,
myPollsEmpty: "저장된 투표가 없습니다.",
deletePoll: "삭제",
closedBadge: "마감됨",
viewResults: "결과 보기",
},
vote: {
loading: "투표방 정보를 불러오는 중...",
Expand Down Expand Up @@ -240,6 +242,10 @@ export const messages = {
justNow: "방금 전",
minutesAgo: (n: number) => `${n}분 전`,
hoursAgo: (n: number) => `${n}시간 전`,
pollClosed: "투표가 마감되었습니다",
pollClosedDescription: "이 투표는 마감되어 결과를 확인할 수 없습니다.",
closedBadge: "마감됨",
expired: "만료",
errors: {
notFound: "투표방을 찾을 수 없습니다",
loadFailed: "투표방 불러오기에 실패했습니다",
Expand Down Expand Up @@ -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...",
Expand Down Expand Up @@ -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",
Expand Down
Loading