feat(notify): push banking move and shipment arrival owner notifications
This commit is contained in:
parent
2a457a5dd9
commit
bc1456db88
2 changed files with 116 additions and 80 deletions
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue