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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user