feat: shop buy, forum replies, tickets, messages, sessions, and UX hardening
Complete remaining product gaps: credit-based shop purchases, guild thread replies, help ticket detail/reply/close, offline message compose, sign-out-everywhere via JWT version, ads delete confirm, soft-fail feedback, rate limits, loading states, and single auth() in site layout. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
ed7db6e048
commit
803e8f36c1
47 files changed
+2793
-358
No files matched your search
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type FriendOutcome =
|
||||
@@ -12,6 +13,15 @@ type FriendOutcome =
|
||||
| "not_found"
|
||||
| "unauthorized"
|
||||
| "invalid"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
type SendOutcome =
|
||||
| "sent"
|
||||
| "not_friend"
|
||||
| "empty"
|
||||
| "invalid"
|
||||
| "rate_limited"
|
||||
| "error";
|
||||
|
||||
function messagesRedirect(outcome: FriendOutcome): never {
|
||||
@@ -25,6 +35,11 @@ function friendsRedirect(outcome: FriendOutcome): never {
|
||||
redirect(`/friends?error=${outcome}`);
|
||||
}
|
||||
|
||||
function sendRedirect(outcome: SendOutcome): never {
|
||||
if (outcome === "sent") redirect("/messages?sent=1");
|
||||
redirect(`/messages?send_error=${outcome}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a pending friend request as the SIGNED-IN user.
|
||||
*
|
||||
@@ -52,6 +67,10 @@ export async function acceptFriend(formData: FormData): Promise<void> {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`friend-accept:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||
outcome = "invalid";
|
||||
@@ -113,6 +132,7 @@ export async function acceptFriend(formData: FormData): Promise<void> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it.
|
||||
if (
|
||||
@@ -148,6 +168,10 @@ export async function declineFriendRequest(formData: FormData): Promise<void> {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`friend-decline:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||
outcome = "invalid";
|
||||
@@ -167,6 +191,7 @@ export async function declineFriendRequest(formData: FormData): Promise<void> {
|
||||
outcome = "declined";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
@@ -202,6 +227,10 @@ export async function removeFriendship(formData: FormData): Promise<void> {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`friend-remove:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const friendId = Number(formData.get("friendId"));
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
outcome = "invalid";
|
||||
@@ -230,6 +259,7 @@ export async function removeFriendship(formData: FormData): Promise<void> {
|
||||
|
||||
outcome = deleted > 0 ? "removed" : "not_found";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
@@ -247,3 +277,81 @@ export async function removeFriendship(formData: FormData): Promise<void> {
|
||||
revalidatePath("/messages");
|
||||
friendsRedirect(outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an offline messenger message to a friend (Arcturus messenger_offline row).
|
||||
* Only confirmed friendships may receive messages; body is capped at 500 chars.
|
||||
*/
|
||||
export async function sendOfflineMessage(formData: FormData): Promise<void> {
|
||||
let outcome: SendOutcome = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const meId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!(await rateLimit(`offline-msg:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "rate_limited";
|
||||
} else {
|
||||
const friendId = Number(formData.get("friendId"));
|
||||
const rawMessage = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const message = rawMessage.slice(0, 500);
|
||||
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
outcome = "invalid";
|
||||
} else if (!message) {
|
||||
outcome = "empty";
|
||||
} else {
|
||||
const friendship = await prisma.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!friendship) {
|
||||
outcome = "not_friend";
|
||||
} else {
|
||||
const recipient = await prisma.user.findUnique({
|
||||
where: { id: friendId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!recipient) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
await prisma.messengerOffline.create({
|
||||
data: {
|
||||
userId: friendId,
|
||||
userFromId: meId,
|
||||
message,
|
||||
sendedOn: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
});
|
||||
outcome = "sent";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/messages");
|
||||
sendRedirect(outcome);
|
||||
}
|
||||
Reference in new issue
Block a user