fix: 修复竞态条件、重置逻辑、无匹配终态等关键问题
- 用 Prisma $transaction 实现 atomicUpdateRoom,防止并发写入覆盖 - 新增 POST /api/room/[id]/reset 端点,修复"再来一轮"按钮死循环 - 新增 swipeCounts 字段追踪滑动进度,检测"无人匹配"终态 - 着陆页 handleCreate 增加 res.ok 检查,防止跳转到无效房间 - 匹配或无匹配后停止轮询,减少无效请求
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getRoomData, updateRoomData } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/store";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -8,28 +8,28 @@ export async function POST(
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getRoomData(id);
|
const { userId } = await req.json();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "userId required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
if (!data) {
|
const updated = await atomicUpdateRoom(id, (data) => {
|
||||||
|
if (!data.users.includes(userId)) {
|
||||||
|
data.users.push(userId);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "房间不存在或已过期" },
|
{ error: "房间不存在或已过期" },
|
||||||
{ status: 404 },
|
{ status: 404 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { userId } = await req.json();
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ error: "userId required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.users.includes(userId)) {
|
|
||||||
data.users.push(userId);
|
|
||||||
await updateRoomData(id, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
roomId: id,
|
roomId: id,
|
||||||
userCount: data.users.length,
|
userCount: updated.users.length,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to join room:", e);
|
console.error("Failed to join room:", e);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { atomicUpdateRoom } from "@/lib/store";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await atomicUpdateRoom(id, (data) => {
|
||||||
|
data.likes = {};
|
||||||
|
data.swipeCounts = {};
|
||||||
|
data.match = null;
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "房间不存在或已过期" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to reset room:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "重置失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,10 +17,17 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const total = data.restaurants.length;
|
||||||
|
const allFinished =
|
||||||
|
data.users.length > 0 &&
|
||||||
|
data.users.every((u) => (data.swipeCounts[u] ?? 0) >= total);
|
||||||
|
const noMatch = allFinished && data.match === null;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
roomId: id,
|
roomId: id,
|
||||||
userCount: data.users.length,
|
userCount: data.users.length,
|
||||||
match: data.match,
|
match: data.match,
|
||||||
|
noMatch,
|
||||||
restaurants: data.restaurants,
|
restaurants: data.restaurants,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getRoomData, updateRoomData } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/store";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -8,15 +8,6 @@ export async function POST(
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getRoomData(id);
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "房间不存在或已过期" },
|
|
||||||
{ status: 404 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { userId, restaurantId, action } = await req.json();
|
const { userId, restaurantId, action } = await req.json();
|
||||||
|
|
||||||
if (!userId || restaurantId == null || !action) {
|
if (!userId || restaurantId == null || !action) {
|
||||||
@@ -27,15 +18,14 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rid = String(restaurantId);
|
const rid = String(restaurantId);
|
||||||
let dirty = false;
|
|
||||||
|
|
||||||
|
const updated = await atomicUpdateRoom(id, (data) => {
|
||||||
if (action === "like") {
|
if (action === "like") {
|
||||||
if (!data.likes[rid]) {
|
if (!data.likes[rid]) {
|
||||||
data.likes[rid] = [];
|
data.likes[rid] = [];
|
||||||
}
|
}
|
||||||
if (!data.likes[rid].includes(userId)) {
|
if (!data.likes[rid].includes(userId)) {
|
||||||
data.likes[rid].push(userId);
|
data.likes[rid].push(userId);
|
||||||
dirty = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -43,17 +33,24 @@ export async function POST(
|
|||||||
data.likes[rid].length === data.users.length
|
data.likes[rid].length === data.users.length
|
||||||
) {
|
) {
|
||||||
data.match = rid;
|
data.match = rid;
|
||||||
dirty = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dirty) {
|
data.swipeCounts[userId] = (data.swipeCounts[userId] ?? 0) + 1;
|
||||||
await updateRoomData(id, data);
|
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "房间不存在或已过期" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
match: data.match,
|
match: updated.match,
|
||||||
likeCount: data.likes[rid]?.length ?? 0,
|
likeCount: updated.likes[rid]?.length ?? 0,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to process swipe:", e);
|
console.error("Failed to process swipe:", e);
|
||||||
|
|||||||
@@ -61,7 +61,15 @@ export default function LandingPage() {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(coords),
|
body: JSON.stringify(coords),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("创建房间失败");
|
||||||
|
}
|
||||||
|
|
||||||
const { roomId } = await res.json();
|
const { roomId } = await res.json();
|
||||||
|
if (!roomId) {
|
||||||
|
throw new Error("创建房间失败");
|
||||||
|
}
|
||||||
|
|
||||||
setLoadingText("正在进入房间...");
|
setLoadingText("正在进入房间...");
|
||||||
await joinRoom(roomId);
|
await joinRoom(roomId);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import TopNav from "@/components/TopNav";
|
import TopNav from "@/components/TopNav";
|
||||||
import SwipeDeck from "@/components/SwipeDeck";
|
import SwipeDeck from "@/components/SwipeDeck";
|
||||||
@@ -14,7 +14,8 @@ export default function RoomPage() {
|
|||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("");
|
||||||
const [joined, setJoined] = useState(false);
|
const [joined, setJoined] = useState(false);
|
||||||
|
|
||||||
const { userCount, match, restaurants } = useRoomPolling(roomId);
|
const { userCount, match, noMatch, restaurants, mutate } =
|
||||||
|
useRoomPolling(roomId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = getUserId();
|
const id = getUserId();
|
||||||
@@ -27,6 +28,11 @@ export default function RoomPage() {
|
|||||||
}).then(() => setJoined(true));
|
}).then(() => setJoined(true));
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
|
|
||||||
|
const handleReset = useCallback(async () => {
|
||||||
|
await fetch(`/api/room/${roomId}/reset`, { method: "POST" });
|
||||||
|
await mutate();
|
||||||
|
}, [roomId, mutate]);
|
||||||
|
|
||||||
const ready = joined && userId && restaurants.length > 0;
|
const ready = joined && userId && restaurants.length > 0;
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
@@ -46,6 +52,8 @@ export default function RoomPage() {
|
|||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
matchedRestaurantId={match}
|
matchedRestaurantId={match}
|
||||||
|
noMatch={noMatch}
|
||||||
|
onReset={handleReset}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Restaurant } from "@/types";
|
|||||||
|
|
||||||
interface MatchResultProps {
|
interface MatchResultProps {
|
||||||
restaurant: Restaurant;
|
restaurant: Restaurant;
|
||||||
onReset: () => void;
|
onReset: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNavUrl(restaurant: Restaurant): string {
|
function buildNavUrl(restaurant: Restaurant): string {
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import { AnimatePresence } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import SwipeableCard from "./SwipeableCard";
|
import SwipeableCard from "./SwipeableCard";
|
||||||
import ActionButtons from "./ActionButtons";
|
import ActionButtons from "./ActionButtons";
|
||||||
import MatchResult from "./MatchResult";
|
import MatchResult from "./MatchResult";
|
||||||
import { Restaurant, SwipeDirection } from "@/types";
|
import { Restaurant, SwipeDirection } from "@/types";
|
||||||
|
import { Frown, RotateCcw } from "lucide-react";
|
||||||
|
|
||||||
interface SwipeDeckProps {
|
interface SwipeDeckProps {
|
||||||
restaurants: Restaurant[];
|
restaurants: Restaurant[];
|
||||||
roomId: string;
|
roomId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
matchedRestaurantId: string | null;
|
matchedRestaurantId: string | null;
|
||||||
|
noMatch: boolean;
|
||||||
|
onReset: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SwipeDeck({
|
export default function SwipeDeck({
|
||||||
@@ -19,20 +22,23 @@ export default function SwipeDeck({
|
|||||||
roomId,
|
roomId,
|
||||||
userId,
|
userId,
|
||||||
matchedRestaurantId,
|
matchedRestaurantId,
|
||||||
|
noMatch,
|
||||||
|
onReset,
|
||||||
}: SwipeDeckProps) {
|
}: SwipeDeckProps) {
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [showMatch, setShowMatch] = useState(false);
|
const [showMatch, setShowMatch] = useState(false);
|
||||||
const [localMatchId, setLocalMatchId] = useState<string | null>(null);
|
const [localMatchId, setLocalMatchId] = useState<string | null>(null);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
const swipeFnRef = useRef<((direction: SwipeDirection) => void) | null>(null);
|
const swipeFnRef = useRef<((direction: SwipeDirection) => void) | null>(null);
|
||||||
const swipingRef = useRef(false);
|
const swipingRef = useRef(false);
|
||||||
|
|
||||||
const resolvedMatchId = matchedRestaurantId ?? localMatchId;
|
const resolvedMatchId = matchedRestaurantId ?? localMatchId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (matchedRestaurantId != null && !showMatch) {
|
if (resolvedMatchId != null && !showMatch) {
|
||||||
setShowMatch(true);
|
setShowMatch(true);
|
||||||
}
|
}
|
||||||
}, [matchedRestaurantId, showMatch]);
|
}, [resolvedMatchId, showMatch]);
|
||||||
|
|
||||||
const registerSwipe = useCallback(
|
const registerSwipe = useCallback(
|
||||||
(fn: (direction: SwipeDirection) => void) => {
|
(fn: (direction: SwipeDirection) => void) => {
|
||||||
@@ -70,15 +76,9 @@ export default function SwipeDeck({
|
|||||||
const nextIndex = currentIndex + 1;
|
const nextIndex = currentIndex + 1;
|
||||||
setCurrentIndex(nextIndex);
|
setCurrentIndex(nextIndex);
|
||||||
swipeFnRef.current = null;
|
swipeFnRef.current = null;
|
||||||
|
|
||||||
if (nextIndex >= restaurants.length && !resolvedMatchId) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!showMatch) setShowMatch(true);
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[currentIndex, restaurants, roomId, userId, resolvedMatchId, showMatch],
|
[currentIndex, restaurants, roomId, userId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleButtonAction = useCallback(
|
const handleButtonAction = useCallback(
|
||||||
@@ -91,26 +91,32 @@ export default function SwipeDeck({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(async () => {
|
||||||
|
setResetting(true);
|
||||||
|
try {
|
||||||
|
await onReset();
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
setShowMatch(false);
|
setShowMatch(false);
|
||||||
setLocalMatchId(null);
|
setLocalMatchId(null);
|
||||||
}, []);
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
}, [onReset]);
|
||||||
|
|
||||||
const isDone = currentIndex >= restaurants.length || resolvedMatchId != null;
|
const allSwiped = currentIndex >= restaurants.length;
|
||||||
|
const isDone = allSwiped || resolvedMatchId != null;
|
||||||
|
|
||||||
const matchRestaurant = resolvedMatchId
|
const matchRestaurant = resolvedMatchId
|
||||||
? restaurants.find((r) => r.id === resolvedMatchId) ?? restaurants[0]
|
? restaurants.find((r) => r.id === resolvedMatchId) ?? null
|
||||||
: restaurants[0];
|
: null;
|
||||||
|
|
||||||
const showWaiting =
|
const showWaiting = allSwiped && !resolvedMatchId && !noMatch;
|
||||||
currentIndex >= restaurants.length && !resolvedMatchId && !showMatch;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex flex-1 items-center justify-center px-4">
|
<div className="relative flex flex-1 items-center justify-center px-4">
|
||||||
<div className="relative h-[70vh] w-full max-w-sm">
|
<div className="relative h-[70vh] w-full max-w-sm">
|
||||||
{!resolvedMatchId && (
|
{!resolvedMatchId && !noMatch && (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{restaurants.map((restaurant, index) => {
|
{restaurants.map((restaurant, index) => {
|
||||||
if (index < currentIndex || index > currentIndex + 1)
|
if (index < currentIndex || index > currentIndex + 1)
|
||||||
@@ -135,10 +141,36 @@ export default function SwipeDeck({
|
|||||||
<p className="text-sm text-zinc-400">等待其他人完成选择...</p>
|
<p className="text-sm text-zinc-400">等待其他人完成选择...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{noMatch && !showMatch && (
|
||||||
|
<motion.div
|
||||||
|
className="flex h-full flex-col items-center justify-center gap-4"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
>
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-zinc-100">
|
||||||
|
<Frown size={32} className="text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-bold text-zinc-700">没有达成共识</p>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
大家口味不太一样,换一批试试?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={resetting}
|
||||||
|
className="mt-2 flex items-center gap-2 rounded-xl bg-emerald-500 px-6 py-2.5 text-sm font-bold text-white shadow-md shadow-emerald-200 transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} className={resetting ? "animate-spin" : ""} />
|
||||||
|
{resetting ? "重置中..." : "再来一轮"}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ActionButtons onAction={handleButtonAction} disabled={isDone} />
|
<ActionButtons onAction={handleButtonAction} disabled={isDone || noMatch} />
|
||||||
|
|
||||||
{showMatch && matchRestaurant && (
|
{showMatch && matchRestaurant && (
|
||||||
<MatchResult restaurant={matchRestaurant} onReset={handleReset} />
|
<MatchResult restaurant={matchRestaurant} onReset={handleReset} />
|
||||||
|
|||||||
@@ -1,22 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import { useRef } from "react";
|
||||||
import { RoomStatus } from "@/types";
|
import { RoomStatus } from "@/types";
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
export function useRoomPolling(roomId: string) {
|
export function useRoomPolling(roomId: string) {
|
||||||
const { data, error, isLoading } = useSWR<RoomStatus>(
|
const settled = useRef(false);
|
||||||
|
|
||||||
|
const { data, error, isLoading, mutate } = useSWR<RoomStatus>(
|
||||||
`/api/room/${roomId}`,
|
`/api/room/${roomId}`,
|
||||||
fetcher,
|
fetcher,
|
||||||
{ refreshInterval: 1500, revalidateOnFocus: true },
|
{
|
||||||
|
refreshInterval: settled.current ? 0 : 1500,
|
||||||
|
revalidateOnFocus: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (data?.match != null || data?.noMatch) {
|
||||||
|
settled.current = true;
|
||||||
|
} else {
|
||||||
|
settled.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userCount: data?.userCount ?? 0,
|
userCount: data?.userCount ?? 0,
|
||||||
match: data?.match ?? null,
|
match: data?.match ?? null,
|
||||||
|
noMatch: data?.noMatch ?? false,
|
||||||
restaurants: data?.restaurants ?? [],
|
restaurants: data?.restaurants ?? [],
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
mutate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-6
@@ -5,6 +5,7 @@ export interface RoomData {
|
|||||||
users: string[];
|
users: string[];
|
||||||
restaurants: Restaurant[];
|
restaurants: Restaurant[];
|
||||||
likes: Record<string, string[]>;
|
likes: Record<string, string[]>;
|
||||||
|
swipeCounts: Record<string, number>;
|
||||||
match: string | null;
|
match: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,11 +13,22 @@ function generateRoomId(): string {
|
|||||||
return String(Math.floor(1000 + Math.random() * 9000));
|
return String(Math.floor(1000 + Math.random() * 9000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalize(raw: Partial<RoomData>): RoomData {
|
||||||
|
return {
|
||||||
|
users: raw.users ?? [],
|
||||||
|
restaurants: raw.restaurants ?? [],
|
||||||
|
likes: raw.likes ?? {},
|
||||||
|
swipeCounts: raw.swipeCounts ?? {},
|
||||||
|
match: raw.match ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function createRoom(restaurants: Restaurant[]): Promise<string> {
|
export async function createRoom(restaurants: Restaurant[]): Promise<string> {
|
||||||
const data: RoomData = {
|
const data: RoomData = {
|
||||||
users: [],
|
users: [],
|
||||||
restaurants,
|
restaurants,
|
||||||
likes: {},
|
likes: {},
|
||||||
|
swipeCounts: {},
|
||||||
match: null,
|
match: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,15 +59,29 @@ export async function getRoomData(
|
|||||||
): Promise<RoomData | null> {
|
): Promise<RoomData | null> {
|
||||||
const room = await prisma.room.findUnique({ where: { id: roomId } });
|
const room = await prisma.room.findUnique({ where: { id: roomId } });
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
return JSON.parse(room.data) as RoomData;
|
return normalize(JSON.parse(room.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRoomData(
|
/**
|
||||||
|
* Atomic read-modify-write within a Prisma transaction.
|
||||||
|
* Prevents race conditions when multiple users swipe concurrently.
|
||||||
|
*/
|
||||||
|
export async function atomicUpdateRoom(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
data: RoomData,
|
updater: (data: RoomData) => RoomData,
|
||||||
): Promise<void> {
|
): Promise<RoomData | null> {
|
||||||
await prisma.room.update({
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const room = await tx.room.findUnique({ where: { id: roomId } });
|
||||||
|
if (!room) return null;
|
||||||
|
|
||||||
|
const data = normalize(JSON.parse(room.data));
|
||||||
|
const updated = updater(data);
|
||||||
|
|
||||||
|
await tx.room.update({
|
||||||
where: { id: roomId },
|
where: { id: roomId },
|
||||||
data: { data: JSON.stringify(data) },
|
data: { data: JSON.stringify(updated) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,6 @@ export interface RoomStatus {
|
|||||||
roomId: string;
|
roomId: string;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
match: string | null;
|
match: string | null;
|
||||||
|
noMatch: boolean;
|
||||||
restaurants: Restaurant[];
|
restaurants: Restaurant[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user