1
0
Fork 0

feat(notify): push banking move and shipment arrival owner notifications

This commit is contained in:
Jason Fraley 2026-08-12 12:38:46 -04:00
parent 2a457a5dd9
commit bc1456db88
2 changed files with 116 additions and 80 deletions

View file

@ -13,6 +13,7 @@ import {
ensurePersonalAccount, ensurePersonalAccount,
getMainCurrencyName, getMainCurrencyName,
} from "@/lib/banking"; } from "@/lib/banking";
import { notifyUser } from "@/lib/notifications";
interface ActionResult<T = undefined> { interface ActionResult<T = undefined> {
success: boolean; success: boolean;
@ -66,10 +67,7 @@ export async function createBankAccount(input: {
const existing = await payload.find({ const existing = await payload.find({
collection: "bank-accounts", collection: "bank-accounts",
where: { where: {
and: [ and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: ownerId } }],
{ accountType: { equals: "personal" } },
{ ownerUser: { equals: ownerId } },
],
}, },
limit: 1, limit: 1,
depth: 0, depth: 0,
@ -100,8 +98,7 @@ export async function createBankAccount(input: {
if (!manager) { if (!manager) {
return { return {
success: false, success: false,
error: error: "Insufficient permissions. Logistics personnel or higher can manage unit accounts.",
"Insufficient permissions. Logistics personnel or higher can manage unit accounts.",
}; };
} }
@ -204,6 +201,8 @@ async function moveFunds(
if (fromAccountId) accountChecks.push({ id: fromAccountId, label: "source" }); if (fromAccountId) accountChecks.push({ id: fromAccountId, label: "source" });
if (toAccountId) accountChecks.push({ id: toAccountId, label: "destination" }); if (toAccountId) accountChecks.push({ id: toAccountId, label: "destination" });
const accountOwners = new Map<number, number | null>();
for (const check of accountChecks) { for (const check of accountChecks) {
const account = (await payload const account = (await payload
.findByID({ .findByID({
@ -224,11 +223,10 @@ async function moveFunds(
if (account.status === "closed") { if (account.status === "closed") {
return { success: false, error: `${check.label} account is closed.` }; return { success: false, error: `${check.label} account is closed.` };
} }
if (account.accountType === "personal") {
const ownerId = const ownerId =
typeof account.ownerUser === "object" typeof account.ownerUser === "object" ? account.ownerUser?.id : account.ownerUser;
? account.ownerUser?.id accountOwners.set(check.id as number, ownerId ?? null);
: account.ownerUser; if (account.accountType === "personal") {
if (!manager && ownerId !== user.id) { if (!manager && ownerId !== user.id) {
return { return {
success: false, success: false,
@ -238,8 +236,7 @@ async function moveFunds(
} else if (!manager) { } else if (!manager) {
return { return {
success: false, success: false,
error: error: "Insufficient permissions. Logistics personnel or higher can move unit funds.",
"Insufficient permissions. Logistics personnel or higher can move unit funds.",
}; };
} }
} }
@ -293,6 +290,66 @@ async function moveFunds(
data: { fromAccountId, toAccountId, amount, type }, 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 }; return { success: true, data: transaction.id };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";

View file

@ -2,10 +2,8 @@ import type { Payload } from "payload";
import type { Shipment, GameVehicle, GameStructure, Vehicle } from "@/payload-types"; import type { Shipment, GameVehicle, GameStructure, Vehicle } from "@/payload-types";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { import { notifyUser } from "@/lib/notifications";
checkStorageDeposit, import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
storageViolationMessage,
} from "@/lib/storageRules";
type ShipmentDoc = Shipment & { type ShipmentDoc = Shipment & {
transportVehicle: GameVehicle & { type: Vehicle }; transportVehicle: GameVehicle & { type: Vehicle };
@ -13,10 +11,7 @@ type ShipmentDoc = Shipment & {
destination: GameStructure; destination: GameStructure;
}; };
export async function processShipmentTick( export async function processShipmentTick(payload: Payload, shipment: ShipmentDoc): Promise<void> {
payload: Payload,
shipment: ShipmentDoc,
): Promise<void> {
const now = new Date(); const now = new Date();
const vehicle = shipment.transportVehicle; const vehicle = shipment.transportVehicle;
@ -28,14 +23,9 @@ export async function processShipmentTick(
const dispatchedAt = new Date(shipment.dispatchedAt!); const dispatchedAt = new Date(shipment.dispatchedAt!);
const estimatedArrival = new Date(shipment.estimatedArrival!); const estimatedArrival = new Date(shipment.estimatedArrival!);
const gameRules = await payload.findGlobal({ slug: "game-rules" }); const gameRules = await payload.findGlobal({ slug: "game-rules" });
const tickIntervalMinutes = const tickIntervalMinutes = (gameRules.gameTickIntervalMinutes as number) || 10;
(gameRules.gameTickIntervalMinutes as number) || 10; const totalDurationMs = estimatedArrival.getTime() - dispatchedAt.getTime();
const totalDurationMs = const totalTicks = Math.max(1, Math.ceil(totalDurationMs / (tickIntervalMinutes * 60 * 1000)));
estimatedArrival.getTime() - dispatchedAt.getTime();
const totalTicks = Math.max(
1,
Math.ceil(totalDurationMs / (tickIntervalMinutes * 60 * 1000)),
);
const fuelPerTick = shipment.fuelCost / totalTicks; const fuelPerTick = shipment.fuelCost / totalTicks;
const currentFuelConsumed = shipment.fuelConsumed ?? 0; const currentFuelConsumed = shipment.fuelConsumed ?? 0;
@ -57,9 +47,7 @@ export async function processShipmentTick(
}); });
const originId = const originId =
typeof shipment.origin === "object" typeof shipment.origin === "object" ? shipment.origin.id : (shipment.origin as number);
? shipment.origin.id
: (shipment.origin as number);
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.ShipmentFail, type: EventTypes.ShipmentFail,
@ -79,10 +67,7 @@ export async function processShipmentTick(
data: { currentFuel: newFuel }, data: { currentFuel: newFuel },
}); });
const newFuelConsumed = Math.min( const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost);
currentFuelConsumed + fuelPerTick,
shipment.fuelCost,
);
if (now >= estimatedArrival) { if (now >= estimatedArrival) {
await completeShipment(payload, shipment, newFuelConsumed); await completeShipment(payload, shipment, newFuelConsumed);
@ -110,9 +95,7 @@ async function completeShipment(
? shipment.destination.id ? shipment.destination.id
: (shipment.destination as number); : (shipment.destination as number);
const originId = const originId =
typeof shipment.origin === "object" typeof shipment.origin === "object" ? shipment.origin.id : (shipment.origin as number);
? shipment.origin.id
: (shipment.origin as number);
const bounced: { itemId: number; amount: number; resourceName: string }[] = []; const bounced: { itemId: number; amount: number; resourceName: string }[] = [];
@ -123,10 +106,7 @@ async function completeShipment(
const polymorphic = itemRef as { relationTo: string; value: number | { id: number } }; const polymorphic = itemRef as { relationTo: string; value: number | { id: number } };
if (polymorphic.relationTo !== "resources") continue; if (polymorphic.relationTo !== "resources") continue;
const itemId = const itemId = typeof polymorphic.value === "object" ? polymorphic.value.id : polymorphic.value;
typeof polymorphic.value === "object"
? polymorphic.value.id
: polymorphic.value;
try { try {
const destination = await payload.findByID({ const destination = await payload.findByID({
@ -139,27 +119,20 @@ async function completeShipment(
if (destType) { if (destType) {
const destStored = destination.storedResources ?? []; const destStored = destination.storedResources ?? [];
const destVoid = (destination as GameStructure).voidStorage ?? []; const destVoid = (destination as GameStructure).voidStorage ?? [];
const currentStored = [...destStored, ...destVoid].reduce( const currentStored = [...destStored, ...destVoid].reduce((sum, r) => {
(sum, r) => { const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
const resId =
typeof r.resource === "object" ? r.resource.id : r.resource;
return sum + (resId === itemId ? r.amount : 0); return sum + (resId === itemId ? r.amount : 0);
}, }, 0);
0,
);
const ruleCheck = checkStorageDeposit( const ruleCheck = checkStorageDeposit(destType, itemId, cargoEntry.amount, currentStored);
destType,
itemId,
cargoEntry.amount,
currentStored,
);
if (!ruleCheck.allowed) { if (!ruleCheck.allowed) {
const resName = const resName =
(await payload (
await payload
.findByID({ collection: "resources", id: itemId, depth: 0 }) .findByID({ collection: "resources", id: itemId, depth: 0 })
.catch(() => null))?.name ?? `resource #${itemId}`; .catch(() => null)
)?.name ?? `resource #${itemId}`;
bounced.push({ bounced.push({
itemId, itemId,
amount: cargoEntry.amount, amount: cargoEntry.amount,
@ -174,8 +147,7 @@ async function completeShipment(
const existingResources = destination.storedResources ?? []; const existingResources = destination.storedResources ?? [];
const existingEntry = existingResources.find((r) => { const existingEntry = existingResources.find((r) => {
const resId = const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
typeof r.resource === "object" ? r.resource.id : r.resource;
return resId === itemId; return resId === itemId;
}); });
@ -224,8 +196,7 @@ async function completeShipment(
const existingResources = [...(origin.storedResources ?? [])]; const existingResources = [...(origin.storedResources ?? [])];
for (const b of bounced) { for (const b of bounced) {
const existingEntry = existingResources.find((r) => { const existingEntry = existingResources.find((r) => {
const resId = const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
typeof r.resource === "object" ? r.resource.id : r.resource;
return resId === b.itemId; return resId === b.itemId;
}); });
if (existingEntry) { if (existingEntry) {
@ -267,7 +238,11 @@ async function completeShipment(
targetId: shipment.id, targetId: shipment.id,
data: { data: {
reason: "storage_rules", reason: "storage_rules",
bounced: bounced.map((b) => ({ itemId: b.itemId, amount: b.amount, resourceName: b.resourceName })), bounced: bounced.map((b) => ({
itemId: b.itemId,
amount: b.amount,
resourceName: b.resourceName,
})),
}, },
}); });
return; return;
@ -292,12 +267,23 @@ async function completeShipment(
data: { vehicleId: vehicle.id, originId, destinationId }, data: { vehicleId: vehicle.id, originId, destinationId },
}); });
// Notify the user who created the shipment (the closest thing to a recipient/owner).
const creatorId =
typeof shipment.createdBy === "object" && shipment.createdBy
? shipment.createdBy.id
: (shipment.createdBy as number | null | undefined);
if (creatorId != null) {
await notifyUser(payload, {
userId: creatorId,
type: "shipment:completed",
title: "Shipment completed",
message: `Shipment "${shipment.name ?? `SHP-${shipment.id}`}" arrived at its destination.`,
link: `/logistics/shipments/${shipment.id}`,
});
}
if (shipment.autoReturn) { if (shipment.autoReturn) {
await createAutoReturnShipment( await createAutoReturnShipment(payload, shipment, vehicle as GameVehicle & { type: Vehicle });
payload,
shipment,
vehicle as GameVehicle & { type: Vehicle },
);
} }
} }
@ -315,20 +301,15 @@ async function createAutoReturnShipment(
? originalShipment.destination.id ? originalShipment.destination.id
: (originalShipment.destination as number); : (originalShipment.destination as number);
const vehicleType = const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null;
typeof vehicle.type === "object" ? vehicle.type : null;
if (!vehicleType) return; if (!vehicleType) return;
const speed = const speed =
vehicleType.maxSpeedOnRoad ?? vehicleType.maxSpeedOnRoad ?? vehicleType.maxSpeedOffRoad ?? vehicleType.maxAirspeed ?? 50;
vehicleType.maxSpeedOffRoad ??
vehicleType.maxAirspeed ??
50;
const rate = vehicleType.fuel?.fuelConsumptionRate ?? 1; const rate = vehicleType.fuel?.fuelConsumptionRate ?? 1;
const returnFuelCost = Math.ceil(originalShipment.distance * rate); const returnFuelCost = Math.ceil(originalShipment.distance * rate);
const returnDurationMs = const returnDurationMs = (originalShipment.distance / speed) * 60 * 60 * 1000;
(originalShipment.distance / speed) * 60 * 60 * 1000;
const now = new Date(); const now = new Date();
if (vehicle.currentFuel < returnFuelCost) { if (vehicle.currentFuel < returnFuelCost) {
@ -366,9 +347,7 @@ async function createAutoReturnShipment(
fuelConsumed: 0, fuelConsumed: 0,
status: "dispatched", status: "dispatched",
dispatchedAt: now.toISOString(), dispatchedAt: now.toISOString(),
estimatedArrival: new Date( estimatedArrival: new Date(now.getTime() + returnDurationMs).toISOString(),
now.getTime() + returnDurationMs,
).toISOString(),
autoReturn: false, autoReturn: false,
createdBy: originalShipment.createdBy, createdBy: originalShipment.createdBy,
}, },