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

+125
View File
@@ -29,6 +29,14 @@ type ThreadOutcome =
| "ratelimit"
| "error";
type ReplyOutcome =
| "replied"
| "invalid"
| "not_found"
| "locked"
| "ratelimit"
| "error";
function profileRedirect(
username: string,
outcome: FriendRequestOutcome,
@@ -47,6 +55,24 @@ function threadRedirect(guildId: number, outcome: ThreadOutcome): never {
redirect(`${base}/new?error=${outcome}`);
}
function threadReplyRedirect(
guildId: number,
threadId: number,
outcome: ReplyOutcome,
): never {
if (
!Number.isInteger(guildId) ||
guildId <= 0 ||
!Number.isInteger(threadId) ||
threadId <= 0
) {
redirect("/guilds");
}
const base = `/guilds/${guildId}/forum/${threadId}`;
if (outcome === "replied") redirect(`${base}?replied=1`);
redirect(`${base}?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
@@ -239,3 +265,102 @@ export async function postThread(formData: FormData): Promise<void> {
}
threadRedirect(guildId, outcome);
}
/**
* Reply to an existing guild forum thread.
*
* The AUTHOR (user_id) is re-read from the session via auth() and is never
* trusted from the submitted FormData. guildId, threadId, and message come
* from the form.
*
* Appends a row to guilds_forums_comments and bumps the thread's posts_count
* and updated_at to match emulator bookkeeping. Locked threads reject replies.
*/
export async function replyToThread(formData: FormData): Promise<void> {
const guildId = Number(formData.get("guildId"));
const threadId = Number(formData.get("threadId"));
let outcome: ReplyOutcome = "error";
try {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
if (
!Number.isInteger(guildId) ||
guildId <= 0 ||
!Number.isInteger(threadId) ||
threadId <= 0
) {
outcome = "invalid";
} else {
await clientIp();
if (!(await rateLimit(`forum-reply:${userId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, MESSAGE_MAX);
if (!message) {
outcome = "invalid";
} else {
const now = Math.floor(Date.now() / 1000);
const thread = await prisma.guildsForumsThreads.findFirst({
where: { id: threadId, guildId, state: 0 },
select: {
id: true,
locked: true,
postsCount: true,
},
});
if (!thread) {
outcome = "not_found";
} else if (thread.locked) {
outcome = "locked";
} else {
await prisma.$transaction(async (tx) => {
await tx.guildsForumsComments.create({
data: {
threadId: thread.id,
userId,
message,
createdAt: now,
state: 0,
adminId: 0,
},
});
await tx.guildsForumsThreads.update({
where: { id: thread.id },
data: {
postsCount: (thread.postsCount ?? 0) + 1,
updatedAt: now,
},
});
});
outcome = "replied";
}
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
if (
Number.isInteger(guildId) &&
guildId > 0 &&
Number.isInteger(threadId) &&
threadId > 0
) {
revalidatePath(`/guilds/${guildId}/forum`);
revalidatePath(`/guilds/${guildId}/forum/${threadId}`);
}
threadReplyRedirect(guildId, threadId, outcome);
}