feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Ship product gaps: register/vote pages, friend add/accept/decline/remove, email verify TTL, captcha on login/forgot, soft-fail user actions, SSE abort/shared client, Commando Centrum error toasts, admin delete for events/polls, and IT/NL i18n fills. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
2ff08e5127
commit
ed7db6e048
76 files changed
+4834
-1376
No files matched your search
+217
-43
@@ -1,9 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type FriendOutcome =
|
||||
| "accepted"
|
||||
| "declined"
|
||||
| "removed"
|
||||
| "not_found"
|
||||
| "unauthorized"
|
||||
| "invalid"
|
||||
| "error";
|
||||
|
||||
function messagesRedirect(outcome: FriendOutcome): never {
|
||||
if (outcome === "accepted") redirect("/messages?accepted=1");
|
||||
if (outcome === "declined") redirect("/messages?declined=1");
|
||||
redirect(`/messages?error=${outcome}`);
|
||||
}
|
||||
|
||||
function friendsRedirect(outcome: FriendOutcome): never {
|
||||
if (outcome === "removed") redirect("/friends?removed=1");
|
||||
redirect(`/friends?error=${outcome}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a pending friend request as the SIGNED-IN user.
|
||||
*
|
||||
@@ -16,60 +37,213 @@ import { prisma } from "@/lib/prisma";
|
||||
* messenger_friendships (one user_one_id→user_two_id, one the reverse). We
|
||||
* create both inside a transaction and delete the originating request so it no
|
||||
* longer shows as pending in the in-game messenger or here.
|
||||
*
|
||||
* Errors redirect back to /messages with a machine-readable ?error= code;
|
||||
* success redirects with ?accepted=1. redirect() is called OUTSIDE the
|
||||
* try/catch so its control-flow throw is never swallowed.
|
||||
*/
|
||||
export async function acceptFriend(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const meId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) return;
|
||||
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) return;
|
||||
let outcome: FriendOutcome = "error";
|
||||
|
||||
try {
|
||||
// The request must exist AND be addressed to the session user.
|
||||
const request = await prisma.messengerFriendrequests.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, userFromId: true, userToId: true },
|
||||
});
|
||||
if (!request || request.userToId !== meId) return;
|
||||
|
||||
const friendId = request.userFromId;
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
// Malformed/self request — just clear it.
|
||||
await prisma.messengerFriendrequests.delete({ where: { id: requestId } });
|
||||
return;
|
||||
const session = await auth();
|
||||
const meId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const friendsSince = Math.floor(Date.now() / 1000);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Don't double-insert if a friendship already exists in either direction.
|
||||
const existing = await tx.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
// The request must exist AND be addressed to the session user.
|
||||
const request = await prisma.messengerFriendrequests.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, userFromId: true, userToId: true },
|
||||
});
|
||||
if (!request) {
|
||||
outcome = "not_found";
|
||||
} else if (request.userToId !== meId) {
|
||||
outcome = "unauthorized";
|
||||
} else {
|
||||
const friendId = request.userFromId;
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
// Malformed/self request — clear it and treat as not found.
|
||||
await prisma.messengerFriendrequests.delete({
|
||||
where: { id: requestId },
|
||||
});
|
||||
outcome = "not_found";
|
||||
} else {
|
||||
const friendsSince = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (!existing) {
|
||||
await tx.messengerFriendships.createMany({
|
||||
data: [
|
||||
{ userOneId: meId, userTwoId: friendId, friendsSince },
|
||||
{ userOneId: friendId, userTwoId: meId, friendsSince },
|
||||
],
|
||||
});
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Don't double-insert if a friendship already exists in either direction.
|
||||
const existing = await tx.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await tx.messengerFriendships.createMany({
|
||||
data: [
|
||||
{ userOneId: meId, userTwoId: friendId, friendsSince },
|
||||
{ userOneId: friendId, userTwoId: meId, friendsSince },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Clear this request and any reverse pending request between the pair.
|
||||
await tx.messengerFriendrequests.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: requestId },
|
||||
{ userFromId: meId, userToId: friendId },
|
||||
{ userFromId: friendId, userToId: meId },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
outcome = "accepted";
|
||||
}
|
||||
}
|
||||
|
||||
await tx.messengerFriendrequests.delete({ where: { id: requestId } });
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it.
|
||||
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");
|
||||
revalidatePath("/friends");
|
||||
messagesRedirect(outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline a pending friend request as the SIGNED-IN user.
|
||||
*
|
||||
* Only the request's target (user_to_id) may decline. Deletes the
|
||||
* messenger_friendrequests row without creating a friendship.
|
||||
*/
|
||||
export async function declineFriendRequest(formData: FormData): Promise<void> {
|
||||
let outcome: FriendOutcome = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const meId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const request = await prisma.messengerFriendrequests.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, userToId: true },
|
||||
});
|
||||
if (!request) {
|
||||
outcome = "not_found";
|
||||
} else if (request.userToId !== meId) {
|
||||
outcome = "unauthorized";
|
||||
} else {
|
||||
await prisma.messengerFriendrequests.delete({
|
||||
where: { id: requestId },
|
||||
});
|
||||
outcome = "declined";
|
||||
}
|
||||
}
|
||||
} 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");
|
||||
messagesRedirect(outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an existing friendship between the SIGNED-IN user and another user.
|
||||
*
|
||||
* Deletes BOTH directional rows in messenger_friendships (Arcturus stores one
|
||||
* row each way) and clears any leftover pending requests between the pair.
|
||||
* Only the friend id comes from the form; the session user is never trusted
|
||||
* from FormData.
|
||||
*/
|
||||
export async function removeFriendship(formData: FormData): Promise<void> {
|
||||
let outcome: FriendOutcome = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const meId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const friendId = Number(formData.get("friendId"));
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const deleted = await prisma.$transaction(async (tx) => {
|
||||
const result = await tx.messengerFriendships.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await tx.messengerFriendrequests.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userFromId: meId, userToId: friendId },
|
||||
{ userFromId: friendId, userToId: meId },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
});
|
||||
|
||||
outcome = deleted > 0 ? "removed" : "not_found";
|
||||
}
|
||||
} 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("/friends");
|
||||
revalidatePath("/messages");
|
||||
friendsRedirect(outcome);
|
||||
}
|
||||
Reference in new issue
Block a user