mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Fix 2 — lastmile should only be yes if EDR last mile.
This commit is contained in:
@@ -158,6 +158,14 @@ export class BookingJourneyService {
|
||||
this.events.emit('booking.completed', { bookingId });
|
||||
}
|
||||
|
||||
// The cargo is physically off the train at its own yard — mid-corridor or
|
||||
// final. WarehouseInventoryService picks this up to create the warehouse
|
||||
// record (import/intercity only; export already has one from receive).
|
||||
this.events.emit('booking.unloadedAtYard', {
|
||||
bookingId,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
});
|
||||
|
||||
// Customer tracking: THIS booking arrived (train may still be rolling).
|
||||
void this.completeMilestones(booking, [
|
||||
...(booking.tradeDirection === 'IMPORT'
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
/**
|
||||
* A mid-corridor booking (import destined at an intermediate yard, or any
|
||||
* DOMESTIC/intercity ride-along) used to have its booking.status flipped by
|
||||
* the checkpoint-driven unload but never got a warehouse_inventory row — the
|
||||
* Arrival Queue's unload count never moved and the booking was effectively
|
||||
* stranded. handleBookingUnloadedAtYard reacts to the 'booking.unloadedAtYard'
|
||||
* event BookingJourneyService.unloadBooking() emits and creates that row.
|
||||
*/
|
||||
function makeService(opts: {
|
||||
existingInventory?: unknown;
|
||||
booking?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const created: Record<string, unknown>[] = [];
|
||||
|
||||
const inventoryRepository = {
|
||||
findAll: jest.fn().mockResolvedValue(opts.existingInventory ? [opts.existingInventory] : []),
|
||||
create: jest.fn((row: Record<string, unknown>) => {
|
||||
created.push(row);
|
||||
return Promise.resolve({ id: 'new-inv', ...row });
|
||||
}),
|
||||
};
|
||||
|
||||
const bookingRow =
|
||||
opts.booking === undefined
|
||||
? [{ weight: '10', freightType: 'CONTAINER', cargoTypeCode: 'GEN', customer: 'Acme' }]
|
||||
: opts.booking
|
||||
? [opts.booking]
|
||||
: [];
|
||||
|
||||
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
|
||||
service.inventoryRepository = inventoryRepository;
|
||||
service.dataSource = { query: jest.fn().mockResolvedValue(bookingRow), manager: {} };
|
||||
service.allocation = { resolveLocation: jest.fn().mockResolvedValue(null) };
|
||||
service.pickDefaultLocation = jest.fn().mockResolvedValue({ warehouseId: 'w1', yardId: 'y1', zoneId: 'z1' });
|
||||
service.applyCapacityDelta = jest.fn().mockResolvedValue(undefined);
|
||||
service.activityLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service.logger = { warn: jest.fn() };
|
||||
|
||||
return { service: service as unknown as WarehouseInventoryService, created };
|
||||
}
|
||||
|
||||
describe('handleBookingUnloadedAtYard', () => {
|
||||
it('creates an UNLOADED row with an IMPORT GRN for a fresh import booking', async () => {
|
||||
const { service, created } = makeService({});
|
||||
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
|
||||
.handleBookingUnloadedAtYard({ bookingId: 'b1', tradeDirection: 'IMPORT' });
|
||||
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0]).toMatchObject({ bookingId: 'b1', status: 'UNLOADED' });
|
||||
expect(created[0].grnNumber).toMatch(/^GRN-IMPORT-/);
|
||||
});
|
||||
|
||||
it('creates one for a DOMESTIC/intercity ride-along too', async () => {
|
||||
const { service, created } = makeService({});
|
||||
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
|
||||
.handleBookingUnloadedAtYard({ bookingId: 'b2', tradeDirection: 'DOMESTIC' });
|
||||
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0].grnNumber).toMatch(/^GRN-DOMESTIC-/);
|
||||
});
|
||||
|
||||
it('skips EXPORT — its warehouse record already exists from the origin receive', async () => {
|
||||
const { service, created } = makeService({});
|
||||
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
|
||||
.handleBookingUnloadedAtYard({ bookingId: 'b3', tradeDirection: 'EXPORT' });
|
||||
|
||||
expect(created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is idempotent — a booking that already has an inventory row is left alone', async () => {
|
||||
const { service, created } = makeService({ existingInventory: { id: 'existing' } });
|
||||
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
|
||||
.handleBookingUnloadedAtYard({ bookingId: 'b4', tradeDirection: 'IMPORT' });
|
||||
|
||||
expect(created).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -95,11 +95,9 @@ export class WarehouseInspectionService {
|
||||
b.company_id AS "companyId",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[inventoryId],
|
||||
@@ -111,8 +109,11 @@ export class WarehouseInspectionService {
|
||||
readyForPickupAt: new Date(),
|
||||
});
|
||||
|
||||
const hasLastMile =
|
||||
Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile);
|
||||
// service_types.includes_last_mile is NOT read here — every service type
|
||||
// ships with it true, which made this always true regardless of the
|
||||
// customer's actual self-haul/EDR-haul choice and permanently dead-coded
|
||||
// the self-haul nudge below. The delivery address is the real signal.
|
||||
const hasLastMile = Boolean(row.lastMileDeliveryAddress?.trim?.());
|
||||
|
||||
if (row.bookingReference && hasLastMile) {
|
||||
await this.lastMileService.acceptBooking(row.bookingReference);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import {
|
||||
Between,
|
||||
@@ -1339,8 +1339,11 @@ export class WarehouseInventoryService {
|
||||
bcu.seal_numbers AS "sealNumbers",
|
||||
bc.container_quantity AS "containerQuantity",
|
||||
bc.container_packaging_type AS "containerPackagingType",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||||
-- service_types.includes_last_mile/first_mile are NOT read here: every
|
||||
-- service type ships with both true, so OR-ing them in made this always
|
||||
-- true regardless of the customer's actual self-haul/EDR-haul choice.
|
||||
-- The address is the only per-booking record of that choice.
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
oy.country AS "originCountry",
|
||||
@@ -1351,8 +1354,7 @@ export class WarehouseInventoryService {
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
b.payment_status AS "paymentStatus",
|
||||
b.status AS "status",
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
|
||||
fm.id AS "firstMileRequestId",
|
||||
fm.status AS "firstMileStatus",
|
||||
fm.vehicle_id AS "firstMileVehicleId",
|
||||
@@ -1386,7 +1388,6 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||
@@ -1494,8 +1495,8 @@ export class WarehouseInventoryService {
|
||||
bc.container_packaging_type AS "containerPackagingType",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||
oy.country AS "originCountry", dy.country AS "destinationCountry",
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
|
||||
-- No service_types OR here either — see eligibleBookings above.
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
|
||||
fm.id AS "firstMileRequestId",
|
||||
fm.status AS "firstMileStatus",
|
||||
v.plate_number AS "firstMileTruckPlateNumber",
|
||||
@@ -1519,14 +1520,12 @@ export class WarehouseInventoryService {
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
b.company_id AS "companyId",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
${primaryContactUserJoin('company')}
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||
@@ -1980,11 +1979,10 @@ export class WarehouseInventoryService {
|
||||
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||||
ts.train_number AS "trainSchedule",
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
-- No service_types OR here either — see eligibleBookings above.
|
||||
CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)
|
||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
|
||||
-- Multi-truck self-haul writes plates/drivers to
|
||||
-- customer_truck_assignments and leaves the booking columns null,
|
||||
-- so read the assignments first and keep the legacy column as the
|
||||
@@ -2019,7 +2017,6 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||
@@ -2113,6 +2110,108 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking alighted from a train at ITS OWN destination yard — emitted by
|
||||
* BookingJourneyService.unloadBooking() for every direction, whether that
|
||||
* yard is a mid-corridor stop (checkpoint auto-unload) or the train's final
|
||||
* yard (manual per-booking unload). That per-booking flow only ever flips
|
||||
* booking.status; it never creates a warehouse_inventory row, which used to
|
||||
* strand mid-corridor IMPORT and DOMESTIC/intercity bookings — their status
|
||||
* read ARRIVED/COMPLETED but the Arrival Queue's unload count never moved
|
||||
* (nothing else was watching for a mid-corridor arrival). This creates that
|
||||
* row the moment the cargo is physically off the train.
|
||||
*
|
||||
* EXPORT is deliberately skipped: its warehouse_inventory row (and GRN) is
|
||||
* created at the ORIGIN warehouse receive, before the cargo ever boards —
|
||||
* see BookingsService.carriageAcceptanceSheet and receive()/bulkReceive()
|
||||
* above. Creating a second row here would duplicate that receipt.
|
||||
*
|
||||
* Idempotent — a booking already unloaded via this listener, a retried
|
||||
* checkpoint, or the final-yard "Auto Unload" bulk action is left alone.
|
||||
*/
|
||||
@OnEvent('booking.unloadedAtYard')
|
||||
async handleBookingUnloadedAtYard(payload: {
|
||||
bookingId: string;
|
||||
tradeDirection: string | null;
|
||||
}): Promise<void> {
|
||||
if (payload.tradeDirection !== 'IMPORT' && payload.tradeDirection !== 'DOMESTIC') return;
|
||||
try {
|
||||
const existing = (
|
||||
await this.inventoryRepository.findAll({ where: { bookingId: payload.bookingId } })
|
||||
)[0];
|
||||
if (existing) return;
|
||||
|
||||
const [booking]: Array<{
|
||||
weight: string | null;
|
||||
freightType: string | null;
|
||||
cargoTypeCode: string | null;
|
||||
customer: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT COALESCE(
|
||||
NULLIF(b.cargo_total_weight_vgm, 0),
|
||||
(SELECT SUM(bcu.vgm_tons)
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc2
|
||||
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
|
||||
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
|
||||
) AS weight,
|
||||
b.freight_type AS "freightType",
|
||||
cgt.code AS "cargoTypeCode",
|
||||
company.name AS customer
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[payload.bookingId],
|
||||
);
|
||||
if (!booking) return;
|
||||
|
||||
const allocated = await this.allocation.resolveLocation({
|
||||
freightType: booking.freightType,
|
||||
tradeDirection: payload.tradeDirection,
|
||||
cargoTypeCode: booking.cargoTypeCode,
|
||||
});
|
||||
const location = allocated ?? (await this.pickDefaultLocation());
|
||||
if (!location) {
|
||||
this.logger.warn(
|
||||
`Checkpoint auto-unload for booking ${payload.bookingId}: no warehouse/yard/zone configured`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const saved = await this.inventoryRepository.create({
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
zoneId: location.zoneId,
|
||||
bookingId: payload.bookingId,
|
||||
quantity: 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
status: 'UNLOADED',
|
||||
grnNumber: this.generateGrnNumber(payload.tradeDirection, payload.bookingId, now, booking.customer),
|
||||
arrivedAt: now,
|
||||
unloadedAt: now,
|
||||
notes:
|
||||
(allocated as { rule?: { name: string } | null } | null)?.rule
|
||||
? `Unloaded → ${(allocated as { path?: string | null }).path}`
|
||||
: 'Unloaded from arrived train (checkpoint auto-unload)',
|
||||
});
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_UNLOADED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: saved.warehouseId,
|
||||
description: 'Unloaded from arrived train (checkpoint auto-unload)',
|
||||
});
|
||||
if (Number(saved.weight) > 0) {
|
||||
await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Checkpoint auto-unload inventory create failed for ${payload.bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state.
|
||||
* Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect —
|
||||
@@ -2690,16 +2789,16 @@ export class WarehouseInventoryService {
|
||||
if (!bookingId) return;
|
||||
const [booking] = await this.dataSource.query(
|
||||
`SELECT reference,
|
||||
last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||
last_mile_delivery_address AS "lastMileDeliveryAddress"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
const hasLastMile =
|
||||
Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile);
|
||||
// service_types.includes_last_mile is NOT read here — every service type
|
||||
// ships with it true, so it can't distinguish EDR last-mile from self-haul.
|
||||
// The delivery address is the only per-booking record of that choice.
|
||||
const hasLastMile = Boolean(booking?.lastMileDeliveryAddress?.trim?.());
|
||||
if (!booking?.reference || !hasLastMile) return;
|
||||
await this.lastMileService.acceptBooking(booking.reference);
|
||||
}
|
||||
@@ -3062,14 +3161,15 @@ export class WarehouseInventoryService {
|
||||
*/
|
||||
private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
|
||||
const runner = manager ?? this.dataSource;
|
||||
// service_types.includes_last_mile is NOT read here — every service type
|
||||
// ships with it true, which made the second disjunct below unreachable and
|
||||
// this method effectively return true only from an assigned truck.
|
||||
const [row]: Array<{ ok: number }> = await runner.query(
|
||||
`SELECT 1 AS ok
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||
AND (b.customer_truck_assigned_at IS NOT NULL
|
||||
OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL
|
||||
AND COALESCE(st.includes_last_mile, false) = false))`,
|
||||
OR NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL)`,
|
||||
[bookingId],
|
||||
);
|
||||
return Boolean(row);
|
||||
|
||||
@@ -150,8 +150,9 @@ function AllocationRules() {
|
||||
.filter((yard) => yard.code)
|
||||
.map((yard) => ({
|
||||
value: yard.code,
|
||||
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
|
||||
label: `${yard.name} (${yard.code})${yard.warehouse?.code ? ` — ${yard.warehouse.code}` : ''}`,
|
||||
}));
|
||||
const yardNameByCode = Object.fromEntries(yards.map((yard) => [yard.code, yard.name]));
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
@@ -223,7 +224,11 @@ function AllocationRules() {
|
||||
{
|
||||
id: 'targetYard',
|
||||
header: 'Target yard',
|
||||
cell: ({ row }) => <Badge variant="light">{row.original.targetYardCode}</Badge>,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light">
|
||||
{yardNameByCode[row.original.targetYardCode] ?? row.original.targetYardCode}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
@@ -307,7 +312,8 @@ function AllocationRules() {
|
||||
{anyLabel(form.freightType, 'freight type').toLowerCase()} booking
|
||||
{form.cargoTypeCode.trim() ? ` with cargo code ${form.cargoTypeCode.trim()}` : ''}
|
||||
{form.containerStatus.trim() ? ` and container status ${form.containerStatus.trim()}` : ''}{' '}
|
||||
is received, <b>send it to</b> {form.targetYardCode || 'a selected target yard'}.
|
||||
is received, <b>send it to</b>{' '}
|
||||
{(form.targetYardCode && yardNameByCode[form.targetYardCode]) || form.targetYardCode || 'a selected target yard'}.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user