feat: shop buy, forum replies, tickets, messages, sessions, and UX hardening
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 4m23s

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:
SimoandCursor committed 2026-07-21 21:21:02 +02:00
1 parent ed7db6e048
commit 803e8f36c1
47 files changed
+2793 -358

No files matched your search

+108
View File
@@ -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);
}