mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 10:08:21 +00:00
feat(companies): add Transit Agent service linked to transit-agent roster; forwarder/agent onboarding step, assigned-bookings tab, booking assignment + notify, drop agent validity window
This commit is contained in:
@@ -2,14 +2,15 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
||||
import { TransitAgentCountry } from "../entities/transit-agent.entity";
|
||||
|
||||
const toBoolean = ({ value }: { value: unknown }) => {
|
||||
if (typeof value === "boolean") return value;
|
||||
@@ -24,13 +25,18 @@ export class CreateTransitAgentDto {
|
||||
@MaxLength(150)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: "2026-01-01" })
|
||||
@IsDateString()
|
||||
validFrom!: string;
|
||||
|
||||
@ApiProperty({ example: "2026-12-31" })
|
||||
@IsDateString()
|
||||
validTo!: string;
|
||||
/**
|
||||
* Defaults to Djibouti, which is what the whole roster was before Ethiopian
|
||||
* agents were added. Only `ET` agents are offered to a freight forwarder
|
||||
* picking itself during onboarding.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
enum: TransitAgentCountry,
|
||||
default: TransitAgentCountry.Djibouti,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(TransitAgentCountry)
|
||||
country?: TransitAgentCountry;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
|
||||
@@ -2,22 +2,38 @@ import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
/**
|
||||
* Djibouti transit officer GL Djibouti may assign against a shipment's
|
||||
* transit-assignee handshake. Admin-managed so the roster and each officer's
|
||||
* validity window arrive without a code change; `isActive` is the manual
|
||||
* suspend/reactivate switch, independent of the validity window.
|
||||
* Where the agent is licensed. The roster started Djibouti-only (the officers
|
||||
* GL Djibouti assigns), so that is the column default. An Ethiopian transit
|
||||
* agent is the same business as a freight forwarder — a forwarder onboarding on
|
||||
* the portal picks itself from the `ET` entries (`Company.transitAgentId`).
|
||||
*/
|
||||
export enum TransitAgentCountry {
|
||||
Ethiopia = "ET",
|
||||
Djibouti = "DJ",
|
||||
}
|
||||
|
||||
/**
|
||||
* Transit officer GL Djibouti may assign against a shipment's transit-assignee
|
||||
* handshake, and — for the Ethiopian entries — the roster a freight forwarder
|
||||
* registers itself against. Admin-managed so the roster arrives without a code
|
||||
* change; `isActive` is the manual suspend/reactivate switch and the only
|
||||
* thing that decides whether an agent may be assigned or picked.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "transit_agents" })
|
||||
@Index(["isActive"])
|
||||
@Index(["country"])
|
||||
export class TransitAgent extends BaseEntity {
|
||||
@Column({ name: "name", type: "varchar", length: 150 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: "valid_from", type: "date" })
|
||||
validFrom!: string;
|
||||
|
||||
@Column({ name: "valid_to", type: "date" })
|
||||
validTo!: string;
|
||||
@Column({
|
||||
name: "country",
|
||||
type: "varchar",
|
||||
length: 2,
|
||||
enum: TransitAgentCountry,
|
||||
default: TransitAgentCountry.Djibouti,
|
||||
})
|
||||
country!: TransitAgentCountry;
|
||||
|
||||
@Column({ name: "is_active", type: "boolean", default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { PortalCustomer } from "../../common/booking-guards";
|
||||
import {
|
||||
RuleEngineCreate,
|
||||
RuleEngineDelete,
|
||||
@@ -50,16 +51,29 @@ export class TransitAgentsController {
|
||||
});
|
||||
}
|
||||
|
||||
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
|
||||
/** Active officers — the transit-assignee assignment dropdown. */
|
||||
@Get("assignable")
|
||||
@RuleEngineView("transit-agents")
|
||||
@ApiOperation({
|
||||
summary: "List transit agents assignable right now (active and in-window)",
|
||||
})
|
||||
@ApiOperation({ summary: "List active transit agents (assignable)" })
|
||||
findAssignable() {
|
||||
return this.transitAgentsService.findAssignable();
|
||||
}
|
||||
|
||||
/**
|
||||
* The Ethiopian roster, id + name only, for a customer registering as a
|
||||
* freight forwarder to pick itself from. Declared before `:id` so the
|
||||
* literal path is not swallowed by the UUID route.
|
||||
*/
|
||||
@Get("forwarder-options")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"List active Ethiopian transit agents (id + name) a freight forwarder can register as",
|
||||
})
|
||||
findForwarderOptions() {
|
||||
return this.transitAgentsService.findForwarderOptions();
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RuleEngineView("transit-agents")
|
||||
@ApiOperation({ summary: "Get a transit agent by ID" })
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import {
|
||||
EntityManager,
|
||||
LessThanOrEqual,
|
||||
MoreThanOrEqual,
|
||||
Repository,
|
||||
} from "typeorm";
|
||||
import { EntityManager, Repository } from "typeorm";
|
||||
|
||||
import { TransitAgent } from "./entities/transit-agent.entity";
|
||||
import {
|
||||
TransitAgent,
|
||||
TransitAgentCountry,
|
||||
} from "./entities/transit-agent.entity";
|
||||
|
||||
/** What a forwarder picking itself from the roster needs: the id and a label. */
|
||||
export type ForwarderTransitAgentOption = Pick<TransitAgent, "id" | "name">;
|
||||
|
||||
@Injectable()
|
||||
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
||||
@@ -19,14 +20,23 @@ export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Active AND currently inside its validity window (today's date, server-side). */
|
||||
findAssignable(today: string): Promise<TransitAgent[]> {
|
||||
/** Every active agent — the GL assignment dropdown. */
|
||||
findAssignable(): Promise<TransitAgent[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
isActive: true,
|
||||
validFrom: LessThanOrEqual(today),
|
||||
validTo: MoreThanOrEqual(today),
|
||||
},
|
||||
where: { isActive: true },
|
||||
order: { name: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The Ethiopian roster a freight forwarder registers itself against, as
|
||||
* `{ id, name }` only — this is served to customers, who have no business
|
||||
* seeing another agent's email or phone. Suspended agents are left out.
|
||||
*/
|
||||
findForwarderOptions(): Promise<ForwarderTransitAgentOption[]> {
|
||||
return this.repository.find({
|
||||
select: { id: true, name: true },
|
||||
where: { isActive: true, country: TransitAgentCountry.Ethiopia },
|
||||
order: { name: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import {
|
||||
} from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
|
||||
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||
import { TransitAgentCountry } from "./entities/transit-agent.entity";
|
||||
import { TransitAgentsService } from "./transit-agents.service";
|
||||
|
||||
/**
|
||||
* The account half of a transit agent. The roster half (validity window,
|
||||
* assignability) predates this and is untouched — what these lock is that
|
||||
* The account half of a transit agent. The roster half (assignability)
|
||||
* predates this and is untouched — what these lock is that
|
||||
* adding a login did not make an account MANDATORY, since production is full of
|
||||
* roster-only agents that must keep working.
|
||||
*/
|
||||
@@ -36,8 +37,6 @@ describe("TransitAgentsService accounts", () => {
|
||||
|
||||
const base = {
|
||||
name: "Ahmed Bourhan",
|
||||
validFrom: "2026-01-01",
|
||||
validTo: "2026-12-31",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -343,4 +342,102 @@ describe("TransitAgentsService accounts", () => {
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* An Ethiopian transit agent IS a freight forwarder, which signs up on the
|
||||
* portal with its own email and phone. Nothing minted from this side may
|
||||
* claim those first.
|
||||
*/
|
||||
describe("Ethiopian agents carry no contact details or account", () => {
|
||||
const ethiopian = { ...base, country: TransitAgentCountry.Ethiopia };
|
||||
|
||||
it("refuses an email on create", async () => {
|
||||
await expect(
|
||||
service.createWithInvite({ ...ethiopian, email: "ff@example.et" }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(repo.create).not.toHaveBeenCalled();
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a phone number on create", async () => {
|
||||
await expect(
|
||||
service.createWithInvite({ ...ethiopian, phoneNumber: "+251911223344" }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(repo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates the roster entry with neither", async () => {
|
||||
const { agent } = await service.createWithInvite(ethiopian);
|
||||
|
||||
expect(agent.country).toBe(TransitAgentCountry.Ethiopia);
|
||||
expect(agent.hasAccount).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses to invite one", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...ethiopian,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.invite("ta-1", { email: "ff@example.et" }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an email on update", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...ethiopian,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update("ta-1", { email: "ff@example.et" }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the contact details when a Djiboutian row is switched", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
country: TransitAgentCountry.Djibouti,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
email: "a@transit.dj",
|
||||
phoneNumber: "+25377834567",
|
||||
});
|
||||
|
||||
await service.update("ta-1", { country: TransitAgentCountry.Ethiopia });
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
"ta-1",
|
||||
expect.objectContaining({
|
||||
country: TransitAgentCountry.Ethiopia,
|
||||
email: null,
|
||||
phoneNumber: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses the switch when the row already has a portal account", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
country: TransitAgentCountry.Djibouti,
|
||||
isActive: true,
|
||||
userId: "user-1",
|
||||
email: "a@transit.dj",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update("ta-1", { country: TransitAgentCountry.Ethiopia }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,13 +26,16 @@ import { isDomesticPhone } from "../otp/otp.service";
|
||||
import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto";
|
||||
import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto";
|
||||
import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto";
|
||||
import { TransitAgent } from "./entities/transit-agent.entity";
|
||||
import { TransitAgentsRepository } from "./transit-agents.repository";
|
||||
|
||||
export type TransitAgentValidityStatus = "VALID" | "NOT_STARTED" | "EXPIRED";
|
||||
import {
|
||||
TransitAgent,
|
||||
TransitAgentCountry,
|
||||
} from "./entities/transit-agent.entity";
|
||||
import {
|
||||
ForwarderTransitAgentOption,
|
||||
TransitAgentsRepository,
|
||||
} from "./transit-agents.repository";
|
||||
|
||||
export type TransitAgentView = TransitAgent & {
|
||||
validityStatus: TransitAgentValidityStatus;
|
||||
/** True once an IAM account backs this agent — i.e. it can sign in. */
|
||||
hasAccount: boolean;
|
||||
};
|
||||
@@ -52,24 +55,9 @@ type TransitAgentListFilter = {
|
||||
sortOrder?: string;
|
||||
};
|
||||
|
||||
/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */
|
||||
function todayISODate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function validityStatus(
|
||||
agent: Pick<TransitAgent, "validFrom" | "validTo">,
|
||||
): TransitAgentValidityStatus {
|
||||
const today = todayISODate();
|
||||
if (today < agent.validFrom) return "NOT_STARTED";
|
||||
if (today > agent.validTo) return "EXPIRED";
|
||||
return "VALID";
|
||||
}
|
||||
|
||||
function withValidityStatus(agent: TransitAgent): TransitAgentView {
|
||||
function toView(agent: TransitAgent): TransitAgentView {
|
||||
return {
|
||||
...agent,
|
||||
validityStatus: validityStatus(agent),
|
||||
hasAccount: Boolean(agent.userId),
|
||||
};
|
||||
}
|
||||
@@ -92,9 +80,7 @@ export class TransitAgentsService {
|
||||
}> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 500;
|
||||
const sortBy = ["name", "validFrom", "validTo", "isActive"].includes(
|
||||
filter.sortBy ?? "",
|
||||
)
|
||||
const sortBy = ["name", "country", "isActive"].includes(filter.sortBy ?? "")
|
||||
? (filter.sortBy as keyof TransitAgent)
|
||||
: "name";
|
||||
const sortOrder =
|
||||
@@ -108,7 +94,7 @@ export class TransitAgentsService {
|
||||
});
|
||||
|
||||
return {
|
||||
data: data.map(withValidityStatus),
|
||||
data: data.map(toView),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
@@ -118,9 +104,14 @@ export class TransitAgentsService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Active and currently inside its validity window — the DJ assignment dropdown. */
|
||||
/** Every active agent — the DJ assignment dropdown. */
|
||||
async findAssignable(): Promise<TransitAgent[]> {
|
||||
return this.transitAgentsRepository.findAssignable(todayISODate());
|
||||
return this.transitAgentsRepository.findAssignable();
|
||||
}
|
||||
|
||||
/** The Ethiopian roster a freight forwarder picks itself from at onboarding. */
|
||||
findForwarderOptions(): Promise<ForwarderTransitAgentOption[]> {
|
||||
return this.transitAgentsRepository.findForwarderOptions();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<TransitAgentView> {
|
||||
@@ -128,10 +119,10 @@ export class TransitAgentsService {
|
||||
if (!agent) {
|
||||
throw new NotFoundException(`Transit agent ${id} not found`);
|
||||
}
|
||||
return withValidityStatus(agent);
|
||||
return toView(agent);
|
||||
}
|
||||
|
||||
/** Used by the assignment flow — rejects a suspended or out-of-window officer. */
|
||||
/** Used by the assignment flow — rejects a suspended officer. */
|
||||
async getAssignable(id: string): Promise<TransitAgent> {
|
||||
const agent = await this.transitAgentsRepository.findById(id);
|
||||
if (!agent) {
|
||||
@@ -142,11 +133,6 @@ export class TransitAgentsService {
|
||||
`${agent.name} is suspended — pick another transit officer.`,
|
||||
);
|
||||
}
|
||||
if (validityStatus(agent) !== "VALID") {
|
||||
throw new BadRequestException(
|
||||
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
|
||||
);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
@@ -221,6 +207,32 @@ export class TransitAgentsService {
|
||||
return { email, username, phoneNumber };
|
||||
}
|
||||
|
||||
/**
|
||||
* An Ethiopian transit agent never gets a portal account of its own.
|
||||
*
|
||||
* It IS a freight forwarder, and the forwarder signs up on the portal as a
|
||||
* customer with its own email and phone — the same ones staff would type
|
||||
* here. An IAM account minted from this side would then claim that email
|
||||
* first, and the forwarder's own registration would fail with "already
|
||||
* registered". So for `ET` the contact fields are refused outright, and the
|
||||
* invite path is closed.
|
||||
*/
|
||||
private assertNoAccountForEthiopian(
|
||||
country: TransitAgentCountry,
|
||||
dto: {
|
||||
email?: string | null;
|
||||
phoneNumber?: string | null;
|
||||
username?: string;
|
||||
},
|
||||
): void {
|
||||
if (country !== TransitAgentCountry.Ethiopia) return;
|
||||
if (dto.email || dto.phoneNumber || dto.username) {
|
||||
throw new BadRequestException(
|
||||
"An Ethiopian transit agent has no email, phone or portal account here — it registers itself on the portal as a freight forwarder with its own contact details.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a transit agent.
|
||||
*
|
||||
@@ -237,24 +249,18 @@ export class TransitAgentsService {
|
||||
async createWithInvite(
|
||||
dto: CreateTransitAgentDto,
|
||||
): Promise<InvitedTransitAgent> {
|
||||
if (dto.validTo < dto.validFrom) {
|
||||
throw new BadRequestException(
|
||||
"Valid-to date must be on or after valid-from date.",
|
||||
);
|
||||
}
|
||||
|
||||
const base = {
|
||||
name: dto.name.trim(),
|
||||
validFrom: dto.validFrom,
|
||||
validTo: dto.validTo,
|
||||
country: dto.country ?? TransitAgentCountry.Djibouti,
|
||||
isActive: dto.isActive ?? true,
|
||||
};
|
||||
this.assertNoAccountForEthiopian(base.country, dto);
|
||||
|
||||
if (!dto.email) {
|
||||
// Roster-only agent — no account, nothing to send.
|
||||
const agent = await this.transitAgentsRepository.create(base);
|
||||
return {
|
||||
agent: withValidityStatus(agent),
|
||||
agent: toView(agent),
|
||||
activationSentTo: null,
|
||||
activationChannel: null,
|
||||
};
|
||||
@@ -286,7 +292,7 @@ export class TransitAgentsService {
|
||||
// valid without it.
|
||||
const activation = await this.sendActivationLink(agent);
|
||||
return {
|
||||
agent: withValidityStatus(agent),
|
||||
agent: toView(agent),
|
||||
activationSentTo: activation?.maskedTarget ?? null,
|
||||
activationChannel: activation?.channel ?? null,
|
||||
};
|
||||
@@ -312,6 +318,7 @@ export class TransitAgentsService {
|
||||
"This transit agent already has a portal account — resend the activation link instead.",
|
||||
);
|
||||
}
|
||||
this.assertNoAccountForEthiopian(current.country, dto);
|
||||
|
||||
const { email, username, phoneNumber } = await this.prepareAccountFields(
|
||||
dto,
|
||||
@@ -338,7 +345,7 @@ export class TransitAgentsService {
|
||||
|
||||
const activation = await this.sendActivationLink(agent);
|
||||
return {
|
||||
agent: withValidityStatus(agent),
|
||||
agent: toView(agent),
|
||||
activationSentTo: activation?.maskedTarget ?? null,
|
||||
activationChannel: activation?.channel ?? null,
|
||||
};
|
||||
@@ -436,13 +443,6 @@ export class TransitAgentsService {
|
||||
dto: UpdateTransitAgentDto,
|
||||
): Promise<TransitAgentView> {
|
||||
const current = await this.findById(id);
|
||||
const nextValidFrom = dto.validFrom ?? current.validFrom;
|
||||
const nextValidTo = dto.validTo ?? current.validTo;
|
||||
if (nextValidTo < nextValidFrom) {
|
||||
throw new BadRequestException(
|
||||
"Valid-to date must be on or after valid-from date.",
|
||||
);
|
||||
}
|
||||
|
||||
// `username` only ever names an IAM account, and it is chosen once at
|
||||
// account creation. Accepting it here (PartialType inherits it from the
|
||||
@@ -450,6 +450,21 @@ export class TransitAgentsService {
|
||||
const { username: _ignoredUsername, email, phoneNumber, ...rest } = dto;
|
||||
|
||||
const contact: Partial<TransitAgent> = {};
|
||||
const nextCountry = dto.country ?? current.country;
|
||||
if (nextCountry === TransitAgentCountry.Ethiopia) {
|
||||
this.assertNoAccountForEthiopian(nextCountry, { email, phoneNumber });
|
||||
if (current.userId) {
|
||||
// The account already holds the email the forwarder would sign up
|
||||
// with; there is no way to hand it back, so the row stays Djiboutian.
|
||||
throw new BadRequestException(
|
||||
`${current.name} already has a portal account, so it cannot become an Ethiopian transit agent — create a new Ethiopian entry instead.`,
|
||||
);
|
||||
}
|
||||
// Whatever contact details a Djiboutian row carried go with the switch,
|
||||
// so the forwarder's own registration cannot collide with them.
|
||||
contact.email = null;
|
||||
contact.phoneNumber = null;
|
||||
}
|
||||
if (email !== undefined) {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
if (await this.transitAgentsRepository.existsByEmail(normalized, id)) {
|
||||
@@ -483,7 +498,7 @@ export class TransitAgentsService {
|
||||
await this.syncIamContact(updated);
|
||||
}
|
||||
|
||||
return withValidityStatus(updated);
|
||||
return toView(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user