With the MCP plugin enabled, payload.auth() can return a payload-mcp-api-keys doc instead of a User. Add an isPayloadUser type guard and use it in every server action, page, and route that treats the auth result as a real user, so API-key sessions can no longer pass user auth checks. hasRoles now explicitly requires a users-collection doc.
362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
"use server";
|
|
|
|
import config from "@payload-config";
|
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
|
import { getPayload } from "payload";
|
|
import type { User } from "@/payload-types";
|
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
|
import {
|
|
applyTransaction,
|
|
createAccount,
|
|
ensurePersonalAccount,
|
|
getMainCurrencyName,
|
|
} from "@/lib/banking";
|
|
import { notifyUser } from "@/lib/notifications";
|
|
|
|
interface ActionResult<T = undefined> {
|
|
success: boolean;
|
|
error?: string;
|
|
data?: T;
|
|
}
|
|
|
|
async function authenticate() {
|
|
const payloadConfig = await config;
|
|
const payload = await getPayload({ config: payloadConfig });
|
|
const { headers } = await import("next/headers");
|
|
const hdrs = await headers();
|
|
const { user } = await payload.auth({
|
|
headers: hdrs,
|
|
canSetHeaders: false,
|
|
});
|
|
|
|
if (!isPayloadUser(user)) {
|
|
throw new Error("Unauthorized");
|
|
}
|
|
|
|
return { payload, user };
|
|
}
|
|
|
|
async function isBankingManager(
|
|
payload: Awaited<ReturnType<typeof getPayload>>,
|
|
user: User,
|
|
): Promise<boolean> {
|
|
if (await hasPermission(payload, user, "banking:manage")) return true;
|
|
return hasLogisticsQualification(payload, user);
|
|
}
|
|
|
|
export async function createBankAccount(input: {
|
|
name: string;
|
|
accountType: "treasury" | "faction" | "personal";
|
|
ownerFactionId?: number;
|
|
ownerUserId?: number;
|
|
}): Promise<ActionResult<number>> {
|
|
try {
|
|
const { payload, user } = await authenticate();
|
|
const manager = await isBankingManager(payload, user);
|
|
|
|
if (input.accountType === "personal") {
|
|
const ownerId = input.ownerUserId ?? (user.id as number);
|
|
if (!manager && ownerId !== user.id) {
|
|
return {
|
|
success: false,
|
|
error: "You can only create a personal account for yourself.",
|
|
};
|
|
}
|
|
const existing = await payload.find({
|
|
collection: "bank-accounts",
|
|
where: {
|
|
and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: ownerId } }],
|
|
},
|
|
limit: 1,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
if (existing.docs.length > 0) {
|
|
return {
|
|
success: false,
|
|
error: "This user already has a personal account.",
|
|
};
|
|
}
|
|
const account = await createAccount(payload, {
|
|
name: input.name,
|
|
accountType: "personal",
|
|
ownerUserId: ownerId,
|
|
});
|
|
await emitGameEvent(payload, {
|
|
type: EventTypes.BankAccountCreate,
|
|
message: `Created personal account "${account.name}"`,
|
|
actor: user.id,
|
|
targetCollection: "bank-accounts",
|
|
targetId: account.id,
|
|
data: { accountType: "personal", ownerUserId: ownerId },
|
|
});
|
|
return { success: true, data: account.id };
|
|
}
|
|
|
|
if (!manager) {
|
|
return {
|
|
success: false,
|
|
error: "Insufficient permissions. Logistics personnel or higher can manage unit accounts.",
|
|
};
|
|
}
|
|
|
|
if (input.accountType === "faction" && !input.ownerFactionId) {
|
|
return {
|
|
success: false,
|
|
error: "A faction account requires an owner faction.",
|
|
};
|
|
}
|
|
|
|
const account = await createAccount(payload, {
|
|
name: input.name,
|
|
accountType: input.accountType,
|
|
ownerFactionId: input.ownerFactionId,
|
|
});
|
|
await emitGameEvent(payload, {
|
|
type: EventTypes.BankAccountCreate,
|
|
message: `Created ${input.accountType} account "${account.name}"`,
|
|
actor: user.id,
|
|
targetCollection: "bank-accounts",
|
|
targetId: account.id,
|
|
data: { accountType: input.accountType, ownerFactionId: input.ownerFactionId },
|
|
});
|
|
return { success: true, data: account.id };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
if (message === "Unauthorized") {
|
|
return { success: false, error: "You must be logged in." };
|
|
}
|
|
return { success: false, error: message };
|
|
}
|
|
}
|
|
|
|
export async function ensureMyAccount(): Promise<ActionResult<number>> {
|
|
try {
|
|
const { payload, user } = await authenticate();
|
|
const account = await ensurePersonalAccount(payload, user.id as number);
|
|
await emitGameEvent(payload, {
|
|
type: EventTypes.BankAccountCreate,
|
|
message: `Created personal account "${account.name}"`,
|
|
actor: user.id,
|
|
targetCollection: "bank-accounts",
|
|
targetId: account.id,
|
|
data: { accountType: "personal", ownerUserId: user.id },
|
|
});
|
|
return { success: true, data: account.id };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
if (message === "Unauthorized") {
|
|
return { success: false, error: "You must be logged in." };
|
|
}
|
|
return { success: false, error: message };
|
|
}
|
|
}
|
|
|
|
export async function depositFunds(
|
|
accountId: number,
|
|
amount: number,
|
|
memo?: string,
|
|
): Promise<ActionResult<number>> {
|
|
return moveFunds("deposit", undefined, accountId, amount, memo);
|
|
}
|
|
|
|
export async function withdrawFunds(
|
|
accountId: number,
|
|
amount: number,
|
|
memo?: string,
|
|
): Promise<ActionResult<number>> {
|
|
return moveFunds("withdrawal", accountId, undefined, amount, memo);
|
|
}
|
|
|
|
export async function transferFunds(
|
|
fromAccountId: number,
|
|
toAccountId: number,
|
|
amount: number,
|
|
memo?: string,
|
|
): Promise<ActionResult<number>> {
|
|
return moveFunds("transfer", fromAccountId, toAccountId, amount, memo);
|
|
}
|
|
|
|
async function moveFunds(
|
|
type: "deposit" | "withdrawal" | "transfer",
|
|
fromAccountId: number | undefined,
|
|
toAccountId: number | undefined,
|
|
amount: number,
|
|
memo?: string,
|
|
): Promise<ActionResult<number>> {
|
|
try {
|
|
const { payload, user } = await authenticate();
|
|
if (!(await hasPermission(payload, user, "bank-accounts:create"))) {
|
|
return { success: false, error: "Insufficient permissions." };
|
|
}
|
|
if (!amount || amount <= 0) {
|
|
return { success: false, error: "Amount must be greater than zero." };
|
|
}
|
|
|
|
const manager = await isBankingManager(payload, user);
|
|
|
|
const accountChecks: { id?: number; label: string }[] = [];
|
|
if (fromAccountId) accountChecks.push({ id: fromAccountId, label: "source" });
|
|
if (toAccountId) accountChecks.push({ id: toAccountId, label: "destination" });
|
|
|
|
const accountOwners = new Map<number, number | null>();
|
|
|
|
for (const check of accountChecks) {
|
|
const account = (await payload
|
|
.findByID({
|
|
collection: "bank-accounts",
|
|
id: check.id as number,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
.catch(() => null)) as unknown as {
|
|
id: number;
|
|
accountType: string;
|
|
ownerUser?: number | { id: number } | null;
|
|
status: string;
|
|
} | null;
|
|
if (!account) {
|
|
return { success: false, error: `${check.label} account not found.` };
|
|
}
|
|
if (account.status === "closed") {
|
|
return { success: false, error: `${check.label} account is closed.` };
|
|
}
|
|
const ownerId =
|
|
typeof account.ownerUser === "object" ? account.ownerUser?.id : account.ownerUser;
|
|
accountOwners.set(check.id as number, ownerId ?? null);
|
|
if (account.accountType === "personal") {
|
|
if (!manager && ownerId !== user.id) {
|
|
return {
|
|
success: false,
|
|
error: "You can only move funds on your own personal account.",
|
|
};
|
|
}
|
|
} else if (!manager) {
|
|
return {
|
|
success: false,
|
|
error: "Insufficient permissions. Logistics personnel or higher can move unit funds.",
|
|
};
|
|
}
|
|
}
|
|
|
|
if (type === "transfer" && (!fromAccountId || !toAccountId)) {
|
|
return {
|
|
success: false,
|
|
error: "A transfer requires both a source and a destination account.",
|
|
};
|
|
}
|
|
if (type === "deposit" && !toAccountId) {
|
|
return { success: false, error: "A deposit requires a destination account." };
|
|
}
|
|
if (type === "withdrawal" && !fromAccountId) {
|
|
return { success: false, error: "A withdrawal requires a source account." };
|
|
}
|
|
|
|
const transaction = await applyTransaction(payload, {
|
|
type,
|
|
fromAccountId,
|
|
toAccountId,
|
|
amount,
|
|
memo,
|
|
actorId: user.id as number,
|
|
});
|
|
|
|
const currency = await getMainCurrencyName(payload);
|
|
const suffix = currency ? ` ${currency}` : "";
|
|
const amountLabel = `${amount.toLocaleString()}${suffix}`;
|
|
|
|
const eventType =
|
|
type === "deposit"
|
|
? EventTypes.FinanceDeposit
|
|
: type === "withdrawal"
|
|
? EventTypes.FinanceWithdraw
|
|
: EventTypes.FinanceTransfer;
|
|
|
|
const eventMessage =
|
|
type === "deposit"
|
|
? `Deposited ${amountLabel} into account #${toAccountId}`
|
|
: type === "withdrawal"
|
|
? `Withdrew ${amountLabel} from account #${fromAccountId}`
|
|
: `Transferred ${amountLabel} from account #${fromAccountId} to account #${toAccountId}`;
|
|
|
|
await emitGameEvent(payload, {
|
|
type: eventType,
|
|
message: eventMessage,
|
|
actor: user.id,
|
|
targetCollection: "bank-transactions",
|
|
targetId: transaction.id,
|
|
data: { fromAccountId, toAccountId, amount, type },
|
|
});
|
|
|
|
const notifications: { userId: number; message: string; link: string }[] = [];
|
|
if (type === "deposit" && toAccountId) {
|
|
const ownerId = accountOwners.get(toAccountId) ?? null;
|
|
if (ownerId != null) {
|
|
notifications.push({
|
|
userId: ownerId,
|
|
message: `Deposited ${amountLabel} into your account (#${toAccountId}).`,
|
|
link: `/logistics/banking/${toAccountId}`,
|
|
});
|
|
}
|
|
} else if (type === "withdrawal" && fromAccountId) {
|
|
const ownerId = accountOwners.get(fromAccountId) ?? null;
|
|
if (ownerId != null) {
|
|
notifications.push({
|
|
userId: ownerId,
|
|
message: `Withdrew ${amountLabel} from your account (#${fromAccountId}).`,
|
|
link: `/logistics/banking/${fromAccountId}`,
|
|
});
|
|
}
|
|
} else if (type === "transfer" && fromAccountId && toAccountId) {
|
|
const fromOwner = accountOwners.get(fromAccountId) ?? null;
|
|
const toOwner = accountOwners.get(toAccountId) ?? null;
|
|
if (fromOwner != null) {
|
|
notifications.push({
|
|
userId: fromOwner,
|
|
message: `Transferred ${amountLabel} from your account (#${fromAccountId}) to account #${toAccountId}.`,
|
|
link: `/logistics/banking/${fromAccountId}`,
|
|
});
|
|
}
|
|
if (toOwner != null && toOwner !== fromOwner) {
|
|
notifications.push({
|
|
userId: toOwner,
|
|
message: `Received ${amountLabel} in account #${toAccountId} from account #${fromAccountId}.`,
|
|
link: `/logistics/banking/${toAccountId}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const notifType =
|
|
type === "deposit"
|
|
? "finance:deposit"
|
|
: type === "withdrawal"
|
|
? "finance:withdraw"
|
|
: "finance:transfer";
|
|
const notifTitle =
|
|
type === "deposit"
|
|
? "Bank deposit"
|
|
: type === "withdrawal"
|
|
? "Bank withdrawal"
|
|
: "Bank transfer";
|
|
for (const n of notifications) {
|
|
await notifyUser(payload, {
|
|
userId: n.userId,
|
|
type: notifType,
|
|
title: notifTitle,
|
|
message: n.message,
|
|
link: n.link,
|
|
});
|
|
}
|
|
|
|
return { success: true, data: transaction.id };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
if (message === "Unauthorized") {
|
|
return { success: false, error: "You must be logged in." };
|
|
}
|
|
return { success: false, error: message };
|
|
}
|
|
}
|