feat: implement sysadmin subscription management and plan usage cycle recalculation tools
This commit is contained in:
@@ -305,12 +305,6 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
throw new Error("La compañia no existe");
|
||||
}
|
||||
|
||||
const canAdd = await MetricsList.canAddAppointment(companyCheck.ownerId);
|
||||
|
||||
if (!canAdd) {
|
||||
throw new Error("Ha alcanzado el limite de turnos permitidos de acuerdo a su plan.");
|
||||
}
|
||||
|
||||
const serviceCheck = await ServicesManager.services.findOne({
|
||||
_id: data.serviceId,
|
||||
companyId: data.companyId,
|
||||
@@ -443,6 +437,18 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
|
||||
data.comments = isNull<string>(data.comments, "");
|
||||
|
||||
const appointmentReserved = await MetricsList.reserveAppointment({
|
||||
userId: String(companyCheck.ownerId),
|
||||
companyId: data.companyId,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
if (!appointmentReserved) {
|
||||
throw new Error("Ha alcanzado el limite de turnos permitidos de acuerdo a su plan.");
|
||||
}
|
||||
|
||||
let appointmentCreated = false;
|
||||
|
||||
try {
|
||||
const start = dayjs(data.start).clone();
|
||||
const end = dayjs(data.start)
|
||||
@@ -467,6 +473,7 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
comments: data.comments,
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
appointmentCreated = true;
|
||||
|
||||
if (data.repeatId) {
|
||||
const repeat = await RepeatsList.repeats.findOne({
|
||||
@@ -489,8 +496,9 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
}
|
||||
}
|
||||
|
||||
await MetricsList.addAppointment({
|
||||
await MetricsList.metrics.addAppointment({
|
||||
userId: companyCheck.ownerId,
|
||||
companyId: data.companyId,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
@@ -559,6 +567,13 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
return newAppointment;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (!appointmentCreated) {
|
||||
await MetricsList.releaseAppointment({
|
||||
userId: String(companyCheck.ownerId),
|
||||
companyId: data.companyId,
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
throw new Error("Ha ocurrido un error al crear el turno.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { IMetricsDocument } from "./Metrics.Adapter.Mongoose";
|
||||
|
||||
export type MetricsParams = {
|
||||
userId?: string;
|
||||
companyId?: string;
|
||||
quantity?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type DeleteMetricsByCompany = {
|
||||
@@ -45,6 +47,8 @@ export interface IMetricsManager {
|
||||
addEmployee(data: MetricsParams): Promise<void>;
|
||||
addService(data: MetricsParams): Promise<void>;
|
||||
addAppointment(data: MetricsParams): Promise<void>;
|
||||
reserveAppointment(data: MetricsParams): Promise<boolean>;
|
||||
releaseAppointment(data: MetricsParams): Promise<void>;
|
||||
addClient(data: MetricsParams): Promise<void>;
|
||||
canAddOrganization(userId: string): Promise<boolean>;
|
||||
canAddEmployee(userId: string): Promise<boolean>;
|
||||
|
||||
@@ -14,6 +14,7 @@ import AppointmentList from "../Appointments/Appointments";
|
||||
import ClientsList from "../Clients/Clients";
|
||||
import RepeatsList from "../Repeats/Repeats";
|
||||
import { isNull } from "../../helpers/IsNull";
|
||||
import PlanUsageCycleList from "../PlanUsageCycle/PlanUsageCycle";
|
||||
|
||||
class MetricsManager implements IMetricsManager {
|
||||
metrics: MetricsAdapterMongoose;
|
||||
@@ -231,17 +232,64 @@ class MetricsManager implements IMetricsManager {
|
||||
if (plan.limitAppointments < 0) {
|
||||
return true;
|
||||
}
|
||||
const metrics = await this.getMetrics(userId);
|
||||
if (metrics.appointmentsCount >= plan.limitAppointments) {
|
||||
const appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({ userId });
|
||||
if (appointmentsCount >= plan.limitAppointments) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async addAppointment(data: MetricsParams): Promise<void> {
|
||||
if (data.userId) {
|
||||
await PlanUsageCycleList.addAppointment({
|
||||
userId: data.userId,
|
||||
quantity: data.quantity,
|
||||
});
|
||||
}
|
||||
await this.metrics.addAppointment(data);
|
||||
}
|
||||
|
||||
public async reserveAppointment(data: MetricsParams): Promise<boolean> {
|
||||
if (!data.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subscription = await PlanSubscriptionsList.findOne({
|
||||
sessionUser: data.userId,
|
||||
});
|
||||
if (!subscription) {
|
||||
return false;
|
||||
}
|
||||
const plan = await PlansList.plans.findOne({
|
||||
_id: subscription.planId,
|
||||
});
|
||||
if (!plan) {
|
||||
return false;
|
||||
}
|
||||
if (plan.limitAppointments < 0) {
|
||||
await PlanUsageCycleList.addAppointment({
|
||||
userId: data.userId,
|
||||
quantity: data.quantity,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return await PlanUsageCycleList.reserveAppointment({
|
||||
userId: data.userId,
|
||||
quantity: data.quantity,
|
||||
limit: plan.limitAppointments,
|
||||
});
|
||||
}
|
||||
|
||||
public async releaseAppointment(data: MetricsParams): Promise<void> {
|
||||
if (data.userId) {
|
||||
await PlanUsageCycleList.releaseAppointment({
|
||||
userId: data.userId,
|
||||
quantity: data.quantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async addClient(data: MetricsParams): Promise<void> {
|
||||
await this.metrics.addClient(data);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Document, Model, Schema, model } from "mongoose";
|
||||
import {
|
||||
CreatePlanUsageCycleParams,
|
||||
FindPlanUsageCycleParams,
|
||||
IPlanUsageCycle,
|
||||
IPlanUsageCycleAdapter,
|
||||
UpdatePlanUsageCycleParams,
|
||||
} from "./PlanUsageCycle.Interface";
|
||||
|
||||
export interface IPlanUsageCycleDocument extends Omit<IPlanUsageCycle, "id">, Document {}
|
||||
|
||||
export class PlanUsageCycleAdapterMongoose implements IPlanUsageCycleAdapter {
|
||||
schema: Schema;
|
||||
planUsageCycleList: Model<IPlanUsageCycleDocument>;
|
||||
|
||||
constructor() {
|
||||
this.schema = new Schema({
|
||||
userId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
|
||||
subscriptionId: { type: Schema.Types.ObjectId, required: false, ref: "PlanSuscription" },
|
||||
planId: { type: Schema.Types.ObjectId, required: true, ref: "Plan" },
|
||||
cycleStart: { type: Date, required: true },
|
||||
cycleEnd: { type: Date, required: true },
|
||||
appointmentsCount: { type: Number, required: true, default: 0 },
|
||||
creationDate: { type: Date, required: true, default: Date.now },
|
||||
updateDate: { type: Date, required: true, default: Date.now },
|
||||
});
|
||||
|
||||
this.schema.index({ userId: 1, subscriptionId: 1, planId: 1, cycleStart: 1 }, { unique: true });
|
||||
|
||||
this.planUsageCycleList = model<IPlanUsageCycleDocument>("PlanUsageCycle", this.schema);
|
||||
}
|
||||
|
||||
public async findOne(filters: FindPlanUsageCycleParams): Promise<IPlanUsageCycleDocument | null> {
|
||||
return await this.planUsageCycleList.findOne(filters);
|
||||
}
|
||||
|
||||
public async create(data: CreatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
|
||||
return await this.planUsageCycleList.create({
|
||||
...data,
|
||||
appointmentsCount: data.appointmentsCount || 0,
|
||||
updateDate: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
public async getOrCreateCurrent(data: UpdatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
|
||||
const now = new Date();
|
||||
return await this.planUsageCycleList.findOneAndUpdate(
|
||||
{
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
},
|
||||
{
|
||||
$setOnInsert: {
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
cycleEnd: data.cycleEnd,
|
||||
appointmentsCount: 0,
|
||||
creationDate: now,
|
||||
},
|
||||
$set: { updateDate: now },
|
||||
},
|
||||
{ new: true, upsert: true }
|
||||
);
|
||||
}
|
||||
|
||||
public async incrementAppointments(
|
||||
data: UpdatePlanUsageCycleParams & { quantity: number }
|
||||
): Promise<IPlanUsageCycleDocument | null> {
|
||||
const now = new Date();
|
||||
return await this.planUsageCycleList.findOneAndUpdate(
|
||||
{
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
},
|
||||
{
|
||||
$setOnInsert: {
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
cycleEnd: data.cycleEnd,
|
||||
appointmentsCount: 0,
|
||||
creationDate: now,
|
||||
},
|
||||
$inc: { appointmentsCount: data.quantity },
|
||||
$set: { updateDate: now },
|
||||
},
|
||||
{ new: true, upsert: true }
|
||||
);
|
||||
}
|
||||
|
||||
public async incrementAppointmentsIfWithinLimit(
|
||||
data: UpdatePlanUsageCycleParams & { quantity: number; limit: number }
|
||||
): Promise<IPlanUsageCycleDocument | null> {
|
||||
const now = new Date();
|
||||
return await this.planUsageCycleList.findOneAndUpdate(
|
||||
{
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
appointmentsCount: { $lte: data.limit - data.quantity },
|
||||
},
|
||||
{
|
||||
$setOnInsert: {
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
cycleEnd: data.cycleEnd,
|
||||
appointmentsCount: 0,
|
||||
creationDate: now,
|
||||
},
|
||||
$inc: { appointmentsCount: data.quantity },
|
||||
$set: { updateDate: now },
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
}
|
||||
|
||||
public async setAppointmentsCount(
|
||||
data: UpdatePlanUsageCycleParams & { appointmentsCount: number }
|
||||
): Promise<IPlanUsageCycleDocument | null> {
|
||||
const now = new Date();
|
||||
return await this.planUsageCycleList.findOneAndUpdate(
|
||||
{
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
},
|
||||
{
|
||||
$setOnInsert: {
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
cycleEnd: data.cycleEnd,
|
||||
creationDate: now,
|
||||
},
|
||||
$set: {
|
||||
appointmentsCount: Math.max(data.appointmentsCount, 0),
|
||||
updateDate: now,
|
||||
},
|
||||
},
|
||||
{ new: true, upsert: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { IPlanUsageCycleDocument } from "./PlanUsageCycle.Adapter.Mongoose";
|
||||
|
||||
export interface IPlanUsageCycle {
|
||||
id?: string;
|
||||
userId: string;
|
||||
subscriptionId?: string;
|
||||
planId: string;
|
||||
cycleStart: Date;
|
||||
cycleEnd: Date;
|
||||
appointmentsCount: number;
|
||||
creationDate: Date;
|
||||
updateDate: Date;
|
||||
}
|
||||
|
||||
export type FindPlanUsageCycleParams = {
|
||||
userId?: string;
|
||||
subscriptionId?: string;
|
||||
planId?: string;
|
||||
cycleStart?: Date;
|
||||
cycleEnd?: Date;
|
||||
};
|
||||
|
||||
export type CreatePlanUsageCycleParams = {
|
||||
userId: string;
|
||||
subscriptionId?: string;
|
||||
planId: string;
|
||||
cycleStart: Date;
|
||||
cycleEnd: Date;
|
||||
appointmentsCount?: number;
|
||||
};
|
||||
|
||||
export type CurrentPlanUsageCycleParams = {
|
||||
userId: string;
|
||||
now?: Date;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type SetPlanUsageCycleAppointmentsParams = CurrentPlanUsageCycleParams & {
|
||||
appointmentsCount: number;
|
||||
};
|
||||
|
||||
export type SysAdminRecalculatePlanUsageCycleParams = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type SysAdminRecalculatePlanUsageCycleResult = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
planId: string;
|
||||
cycleStart: Date;
|
||||
cycleEnd: Date;
|
||||
appointmentsCount: number;
|
||||
};
|
||||
|
||||
export type UpdatePlanUsageCycleParams = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
planId: string;
|
||||
cycleStart: Date;
|
||||
cycleEnd: Date;
|
||||
};
|
||||
|
||||
export type CalculateCurrentCycleParams = {
|
||||
subscriptionStartDate: Date;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
export type PlanUsageCycleRange = {
|
||||
cycleStart: Date;
|
||||
cycleEnd: Date;
|
||||
};
|
||||
|
||||
export interface IPlanUsageCycleAdapter {
|
||||
findOne(filters: FindPlanUsageCycleParams): Promise<IPlanUsageCycleDocument | null>;
|
||||
create(data: CreatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
|
||||
getOrCreateCurrent(data: UpdatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
|
||||
incrementAppointments(data: UpdatePlanUsageCycleParams & { quantity: number }): Promise<IPlanUsageCycleDocument | null>;
|
||||
incrementAppointmentsIfWithinLimit(
|
||||
data: UpdatePlanUsageCycleParams & { quantity: number; limit: number }
|
||||
): Promise<IPlanUsageCycleDocument | null>;
|
||||
setAppointmentsCount(
|
||||
data: UpdatePlanUsageCycleParams & { appointmentsCount: number }
|
||||
): Promise<IPlanUsageCycleDocument | null>;
|
||||
}
|
||||
|
||||
export interface IPlanUsageCycleManager {
|
||||
planUsageCycle: IPlanUsageCycleAdapter;
|
||||
getCurrentCycle(data: CurrentPlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
|
||||
addAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void>;
|
||||
reserveAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<boolean>;
|
||||
releaseAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void>;
|
||||
getAppointmentsCount(data: CurrentPlanUsageCycleParams): Promise<number>;
|
||||
setAppointmentsCount(data: SetPlanUsageCycleAppointmentsParams): Promise<IPlanUsageCycleDocument | null>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import dayjs from "dayjs";
|
||||
import { CalculateCurrentCycleParams, PlanUsageCycleRange } from "./PlanUsageCycle.Interface";
|
||||
|
||||
export function calculateCurrentPlanUsageCycle(data: CalculateCurrentCycleParams): PlanUsageCycleRange {
|
||||
const now = dayjs(data.now || new Date());
|
||||
const anchor = dayjs(data.subscriptionStartDate);
|
||||
|
||||
let cycleStart = anchor.clone();
|
||||
let cycleEnd = cycleStart.clone().add(1, "month");
|
||||
|
||||
while (!cycleEnd.isAfter(now)) {
|
||||
cycleStart = cycleEnd;
|
||||
cycleEnd = cycleStart.clone().add(1, "month");
|
||||
}
|
||||
|
||||
return {
|
||||
cycleStart: cycleStart.toDate(),
|
||||
cycleEnd: cycleEnd.toDate(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { isNull } from "../../helpers/IsNull";
|
||||
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
|
||||
import {
|
||||
CurrentPlanUsageCycleParams,
|
||||
IPlanUsageCycleManager,
|
||||
SetPlanUsageCycleAppointmentsParams,
|
||||
UpdatePlanUsageCycleParams,
|
||||
} from "./PlanUsageCycle.Interface";
|
||||
import {
|
||||
IPlanUsageCycleDocument,
|
||||
PlanUsageCycleAdapterMongoose,
|
||||
} from "./PlanUsageCycle.Adapter.Mongoose";
|
||||
import { calculateCurrentPlanUsageCycle } from "./PlanUsageCycle.helpers";
|
||||
|
||||
export { calculateCurrentPlanUsageCycle } from "./PlanUsageCycle.helpers";
|
||||
|
||||
class PlanUsageCycleManager implements IPlanUsageCycleManager {
|
||||
planUsageCycle: PlanUsageCycleAdapterMongoose;
|
||||
|
||||
constructor() {
|
||||
this.planUsageCycle = new PlanUsageCycleAdapterMongoose();
|
||||
}
|
||||
|
||||
private async getCurrentCycleData(data: CurrentPlanUsageCycleParams): Promise<UpdatePlanUsageCycleParams> {
|
||||
const subscription = await PlanSubscriptionsList.findOne({
|
||||
sessionUser: data.userId,
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
throw new Error("No se ha encontrado una suscripción activa");
|
||||
}
|
||||
|
||||
const { cycleStart, cycleEnd } = calculateCurrentPlanUsageCycle({
|
||||
subscriptionStartDate: subscription.startDate,
|
||||
now: data.now,
|
||||
});
|
||||
|
||||
return {
|
||||
userId: data.userId,
|
||||
subscriptionId: String(subscription.id),
|
||||
planId: String(subscription.planId),
|
||||
cycleStart,
|
||||
cycleEnd,
|
||||
};
|
||||
}
|
||||
|
||||
public async getCurrentCycle(data: CurrentPlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
|
||||
return await this.planUsageCycle.getOrCreateCurrent(await this.getCurrentCycleData(data));
|
||||
}
|
||||
|
||||
public async addAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void> {
|
||||
await this.planUsageCycle.incrementAppointments({
|
||||
...(await this.getCurrentCycleData(data)),
|
||||
quantity: isNull<number>(data.quantity, 1),
|
||||
});
|
||||
}
|
||||
|
||||
public async reserveAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<boolean> {
|
||||
if (typeof data.limit !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const quantity = isNull<number>(data.quantity, 1);
|
||||
const usageCycleData = await this.getCurrentCycleData(data);
|
||||
const usageCycle = await this.planUsageCycle.getOrCreateCurrent(usageCycleData);
|
||||
|
||||
if (usageCycle.appointmentsCount + quantity > data.limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updatedCycle = await this.planUsageCycle.incrementAppointmentsIfWithinLimit({
|
||||
...usageCycleData,
|
||||
quantity,
|
||||
limit: data.limit,
|
||||
});
|
||||
|
||||
return !!updatedCycle;
|
||||
}
|
||||
|
||||
public async releaseAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void> {
|
||||
await this.addAppointment({
|
||||
...data,
|
||||
quantity: -Math.abs(isNull<number>(data.quantity, 1)),
|
||||
});
|
||||
}
|
||||
|
||||
public async getAppointmentsCount(data: CurrentPlanUsageCycleParams): Promise<number> {
|
||||
const usageCycle = await this.getCurrentCycle(data);
|
||||
return usageCycle.appointmentsCount;
|
||||
}
|
||||
|
||||
public async setAppointmentsCount(data: SetPlanUsageCycleAppointmentsParams): Promise<IPlanUsageCycleDocument | null> {
|
||||
return await this.planUsageCycle.setAppointmentsCount({
|
||||
...(await this.getCurrentCycleData(data)),
|
||||
appointmentsCount: data.appointmentsCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const PlanUsageCycleList = new PlanUsageCycleManager();
|
||||
|
||||
export default PlanUsageCycleList;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FindPlansParams, IPlansAdapter, IPlan } from "./Plans.interface";
|
||||
import { FindPlansParams, IPlansAdapter, IPlan, SysAdminUpdatePlanParams } from "./Plans.interface";
|
||||
import { Document, Model, Schema, model } from "mongoose";
|
||||
|
||||
export interface IPlanDocument extends Omit<IPlan, "id">, Document {}
|
||||
@@ -45,4 +45,8 @@ export class PlansAdapterMongoose implements IPlansAdapter {
|
||||
public async findOne(filters: FindPlansParams): Promise<IPlanDocument | null> {
|
||||
return this.planList.findOne(filters).exec();
|
||||
}
|
||||
|
||||
public async updateOne(planId: string, data: Omit<SysAdminUpdatePlanParams, "planId">): Promise<void> {
|
||||
await this.planList.updateOne({ _id: planId }, { $set: data }).exec();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,33 @@ export type FindPlansParams = {
|
||||
price?: number;
|
||||
};
|
||||
|
||||
export type SysAdminUpdatePlanParams = {
|
||||
planId: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
features?: string[];
|
||||
code?: string;
|
||||
price?: number;
|
||||
annualPrice?: number;
|
||||
limitOrganizations?: number;
|
||||
limitEmployees?: number;
|
||||
limitServices?: number;
|
||||
limitAppointments?: number;
|
||||
limitClients?: number;
|
||||
limitRepeats?: number;
|
||||
mailNotifications?: boolean;
|
||||
smsNotifications?: boolean;
|
||||
wapNotifications?: boolean;
|
||||
payments?: boolean;
|
||||
bot?: boolean;
|
||||
active?: boolean;
|
||||
dateLimit?: boolean;
|
||||
discount3Months?: number;
|
||||
discount6Months?: number;
|
||||
discount12Months?: number;
|
||||
featured?: boolean;
|
||||
};
|
||||
|
||||
export interface IPlan {
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -44,10 +71,12 @@ export interface IPlan {
|
||||
export interface IPlansAdapter {
|
||||
find(filters: FindPlansParams): Promise<IPlan[]>;
|
||||
findOne(filters: FindPlansParams): Promise<IPlan | null>;
|
||||
updateOne(planId: string, data: Omit<SysAdminUpdatePlanParams, "planId">): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IPlansManager {
|
||||
plans: IPlansAdapter;
|
||||
find(data: FindPlansParams): Promise<IPlan[]>;
|
||||
findOne(data: FindPlansParams): Promise<IPlan>;
|
||||
sysAdminUpdatePlan(data: SysAdminUpdatePlanParams): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isNull } from "../../helpers/IsNull";
|
||||
import { PlansAdapterMongoose } from "./Plans.Adapter.Mongoose";
|
||||
import { FindPlansParams, IPlan, IPlansManager } from "./Plans.interface";
|
||||
import { FindPlansParams, IPlan, IPlansManager, SysAdminUpdatePlanParams } from "./Plans.interface";
|
||||
|
||||
class PlansManager implements IPlansManager {
|
||||
plans: PlansAdapterMongoose;
|
||||
@@ -59,6 +59,12 @@ class PlansManager implements IPlansManager {
|
||||
|
||||
return this.getOnePlan(plan);
|
||||
}
|
||||
|
||||
public async sysAdminUpdatePlan(data: SysAdminUpdatePlanParams): Promise<void> {
|
||||
const { planId, ...planData } = data;
|
||||
await this.findOne({ _id: planId });
|
||||
await this.plans.updateOne(planId, planData);
|
||||
}
|
||||
}
|
||||
|
||||
const PlansList = new PlansManager();
|
||||
|
||||
@@ -205,6 +205,61 @@ export type SysAdminOrganizationsStatusParams = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type SysAdminUserSubscriptionDetailsParams = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type SysAdminExtendUserSubscriptionMode = "add_months" | "set_end_date";
|
||||
|
||||
export type SysAdminExtendUserSubscriptionParams = {
|
||||
userId: string;
|
||||
mode: SysAdminExtendUserSubscriptionMode;
|
||||
months?: 1 | 2;
|
||||
endDate?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type SysAdminExtendUserSubscriptionResult = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
previousEndDate: Date;
|
||||
newEndDate: Date;
|
||||
paymentId: string;
|
||||
transactionId?: string;
|
||||
};
|
||||
|
||||
export type SysAdminUserSubscriptionPayment = {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
amount: number;
|
||||
paymentDate: Date;
|
||||
paymentMethod?: string;
|
||||
status: string;
|
||||
transactionId?: string;
|
||||
};
|
||||
|
||||
export type SysAdminUserSubscriptionDetailsResult = {
|
||||
userId: string;
|
||||
currentSubscription: {
|
||||
id: string;
|
||||
plan: {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
price: number;
|
||||
} | null;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
isActive: boolean;
|
||||
mpStatus?: string;
|
||||
billingMonths?: number;
|
||||
pendingPaymentType?: "extension" | "upgrade";
|
||||
pendingPaymentPreferenceId?: string;
|
||||
lastPaymentStatus?: string;
|
||||
} | null;
|
||||
payments: SysAdminUserSubscriptionPayment[];
|
||||
};
|
||||
|
||||
export type SysAdminUserOrganizationsResult = {
|
||||
clientIn: { companyId: string; companyName: string }[];
|
||||
collaboratorIn: { companyId: string; companyName: string }[];
|
||||
|
||||
@@ -10,6 +10,7 @@ import CashFlowList from "../../Models/CashFlow/CashFlow";
|
||||
import CompaniesList from "../../Models/Companies/Companies";
|
||||
import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
|
||||
import MetricsList from "../../Models/Metrics/Metrics";
|
||||
import PlanUsageCycleList from "../../Models/PlanUsageCycle/PlanUsageCycle";
|
||||
|
||||
export class DashboardService {
|
||||
public async getDashboardSummary(sessionUser: string): Promise<DashboardSummaryResult> {
|
||||
@@ -198,11 +199,18 @@ export class DashboardService {
|
||||
const isOwner = String(ownerId) === String(params.sessionUser);
|
||||
|
||||
// Fetch metrics for owner
|
||||
const metrics = await MetricsList.getMetrics(String(ownerId));
|
||||
const metricsDocument = await MetricsList.getMetrics(String(ownerId));
|
||||
const metrics = metricsDocument.toObject ? metricsDocument.toObject() : { ...metricsDocument };
|
||||
|
||||
// Fetch subscription for owner
|
||||
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: String(ownerId) });
|
||||
|
||||
if (subscription) {
|
||||
metrics.appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({
|
||||
userId: String(ownerId),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
metrics,
|
||||
subscription,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
|
||||
import { ApiValidationError } from "../../../Models/Server.Error.model";
|
||||
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
|
||||
import {
|
||||
SysAdminRecalculatePlanUsageCycleParams,
|
||||
SysAdminRecalculatePlanUsageCycleResult,
|
||||
} from "../../../Models/PlanUsageCycle/PlanUsageCycle.Interface";
|
||||
import { SysAdminPlanUsageCycleService } from "./SysAdminPlanUsageCycle.Service";
|
||||
|
||||
@Route("sysadmin/plan-usage-cycle")
|
||||
@Middlewares(sysAdminAuthMiddleware)
|
||||
export class SysAdminPlanUsageCycleController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post("recalculate-user")
|
||||
public async recalculateUserUsageCycle(
|
||||
@Body() requestBody: SysAdminRecalculatePlanUsageCycleParams
|
||||
): Promise<SysAdminRecalculatePlanUsageCycleResult | ApiValidationError> {
|
||||
try {
|
||||
const result = await new SysAdminPlanUsageCycleService().recalculateUserUsageCycle(requestBody);
|
||||
this.setStatus(200);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { connect } from "mongoose";
|
||||
import dayjs from "dayjs";
|
||||
import AppointmentList from "../../../Models/Appointments/Appointments";
|
||||
import CompaniesList from "../../../Models/Companies/Companies";
|
||||
import PlanUsageCycleList from "../../../Models/PlanUsageCycle/PlanUsageCycle";
|
||||
import {
|
||||
SysAdminRecalculatePlanUsageCycleParams,
|
||||
SysAdminRecalculatePlanUsageCycleResult,
|
||||
} from "../../../Models/PlanUsageCycle/PlanUsageCycle.Interface";
|
||||
|
||||
export class SysAdminPlanUsageCycleService {
|
||||
public async recalculateUserUsageCycle(
|
||||
params: SysAdminRecalculatePlanUsageCycleParams
|
||||
): Promise<SysAdminRecalculatePlanUsageCycleResult> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
|
||||
const usageCycle = await PlanUsageCycleList.getCurrentCycle({ userId: params.userId });
|
||||
const companies = await CompaniesList.companies.find({ ownerId: params.userId });
|
||||
const companyIds = companies.map((company) => String(company.id));
|
||||
|
||||
const appointmentsCount = companyIds.length === 0
|
||||
? 0
|
||||
: await AppointmentList.Appointments.AppointmentList.countDocuments({
|
||||
companyId: { $in: companyIds },
|
||||
creationDate: {
|
||||
$gte: dayjs(usageCycle.cycleStart).toDate(),
|
||||
$lt: dayjs(usageCycle.cycleEnd).toDate(),
|
||||
},
|
||||
});
|
||||
|
||||
const updatedCycle = await PlanUsageCycleList.setAppointmentsCount({
|
||||
userId: params.userId,
|
||||
appointmentsCount,
|
||||
});
|
||||
|
||||
if (!updatedCycle) {
|
||||
throw new Error("No se pudo recalcular el uso del plan.");
|
||||
}
|
||||
|
||||
return {
|
||||
userId: params.userId,
|
||||
subscriptionId: String(updatedCycle.subscriptionId),
|
||||
planId: String(updatedCycle.planId),
|
||||
cycleStart: updatedCycle.cycleStart,
|
||||
cycleEnd: updatedCycle.cycleEnd,
|
||||
appointmentsCount: updatedCycle.appointmentsCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
|
||||
|
||||
import { ApiValidationError } from "../../../Models/Server.Error.model";
|
||||
import { ApiVoidResult } from "../../../Models/Api.VoidResult.type";
|
||||
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
|
||||
import { IPlan, SysAdminUpdatePlanParams } from "../../../Models/Plans/Plans.interface";
|
||||
import { SysAdminPlansService } from "./SysAdminPlans.Service";
|
||||
|
||||
@Route("sysadmin/plans")
|
||||
@Middlewares(sysAdminAuthMiddleware)
|
||||
export class SysAdminPlansController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post("list")
|
||||
public async listPlans(): Promise<IPlan[] | ApiValidationError> {
|
||||
try {
|
||||
const plans = await new SysAdminPlansService().listPlans();
|
||||
this.setStatus(200);
|
||||
return plans;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "El plan fue modificado con éxito")
|
||||
@Post("update")
|
||||
public async updatePlan(@Body() requestBody: SysAdminUpdatePlanParams): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new SysAdminPlansService().updatePlan(requestBody);
|
||||
this.setStatus(200);
|
||||
return {
|
||||
success: true,
|
||||
message: "Done",
|
||||
};
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import PlansList from "../../../Models/Plans/Plans";
|
||||
import { IPlan, SysAdminUpdatePlanParams } from "../../../Models/Plans/Plans.interface";
|
||||
import { connect } from "mongoose";
|
||||
|
||||
export class SysAdminPlansService {
|
||||
public async listPlans(): Promise<IPlan[]> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
return await PlansList.find({});
|
||||
}
|
||||
|
||||
public async updatePlan(data: SysAdminUpdatePlanParams): Promise<void> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
await PlansList.sysAdminUpdatePlan(this.sanitizeUpdate(data));
|
||||
}
|
||||
|
||||
private sanitizeUpdate(data: SysAdminUpdatePlanParams): SysAdminUpdatePlanParams {
|
||||
if (!data.planId) {
|
||||
throw new Error("Plan id is required");
|
||||
}
|
||||
|
||||
const sanitized: SysAdminUpdatePlanParams = { planId: data.planId };
|
||||
const stringFields: (keyof SysAdminUpdatePlanParams)[] = ["name", "description", "code"];
|
||||
const nonNegativeNumberFields: (keyof SysAdminUpdatePlanParams)[] = [
|
||||
"price",
|
||||
"annualPrice",
|
||||
"discount3Months",
|
||||
"discount6Months",
|
||||
"discount12Months",
|
||||
];
|
||||
const limitFields: (keyof SysAdminUpdatePlanParams)[] = [
|
||||
"limitOrganizations",
|
||||
"limitEmployees",
|
||||
"limitServices",
|
||||
"limitAppointments",
|
||||
"limitClients",
|
||||
"limitRepeats",
|
||||
];
|
||||
const booleanFields: (keyof SysAdminUpdatePlanParams)[] = [
|
||||
"mailNotifications",
|
||||
"smsNotifications",
|
||||
"wapNotifications",
|
||||
"payments",
|
||||
"bot",
|
||||
"active",
|
||||
"dateLimit",
|
||||
"featured",
|
||||
];
|
||||
|
||||
for (const field of stringFields) {
|
||||
const value = data[field];
|
||||
if (typeof value === "string") {
|
||||
sanitized[field] = value.trim() as never;
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of nonNegativeNumberFields) {
|
||||
const value = data[field];
|
||||
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
||||
sanitized[field] = value as never;
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of limitFields) {
|
||||
const value = data[field];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
sanitized[field] = value as never;
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of booleanFields) {
|
||||
const value = data[field];
|
||||
if (typeof value === "boolean") {
|
||||
sanitized[field] = value as never;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(data.features)) {
|
||||
sanitized.features = data.features.filter((feature) => typeof feature === "string").map((feature) => feature.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
|
||||
import { ApiValidationError } from "../../../Models/Server.Error.model";
|
||||
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
|
||||
import {
|
||||
SysAdminExtendUserSubscriptionParams,
|
||||
SysAdminExtendUserSubscriptionResult,
|
||||
SysAdminUserSubscriptionDetailsParams,
|
||||
SysAdminUserSubscriptionDetailsResult,
|
||||
} from "../../../Models/Users/Users.Interface";
|
||||
import { SysAdminSubscriptionsService } from "./SysAdminSubscriptions.Service";
|
||||
|
||||
@Route("sysadmin/subscriptions")
|
||||
@Middlewares(sysAdminAuthMiddleware)
|
||||
export class SysAdminSubscriptionsController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post("extend-user")
|
||||
public async extendUserSubscription(
|
||||
@Body() requestBody: SysAdminExtendUserSubscriptionParams
|
||||
): Promise<SysAdminExtendUserSubscriptionResult | ApiValidationError> {
|
||||
try {
|
||||
const result = await new SysAdminSubscriptionsService().extendUserSubscription(requestBody);
|
||||
this.setStatus(200);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post("user-details")
|
||||
public async getUserSubscriptionDetails(
|
||||
@Body() requestBody: SysAdminUserSubscriptionDetailsParams
|
||||
): Promise<SysAdminUserSubscriptionDetailsResult | ApiValidationError> {
|
||||
try {
|
||||
const result = await new SysAdminSubscriptionsService().getUserSubscriptionDetails(requestBody);
|
||||
this.setStatus(200);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { connect, isValidObjectId } from "mongoose";
|
||||
import dayjs from "dayjs";
|
||||
import PlanSubscriptionsList from "../../../Models/PlanSubscriptions/PlanSubscriptons";
|
||||
import PlanPaymentsList from "../../../Models/PlanPayments/PlanPayments";
|
||||
import PlansList from "../../../Models/Plans/Plans";
|
||||
import {
|
||||
SysAdminExtendUserSubscriptionParams,
|
||||
SysAdminExtendUserSubscriptionResult,
|
||||
SysAdminUserSubscriptionDetailsParams,
|
||||
SysAdminUserSubscriptionDetailsResult,
|
||||
} from "../../../Models/Users/Users.Interface";
|
||||
|
||||
export class SysAdminSubscriptionsService {
|
||||
public async extendUserSubscription(
|
||||
params: SysAdminExtendUserSubscriptionParams
|
||||
): Promise<SysAdminExtendUserSubscriptionResult> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
|
||||
if (!params.userId || !isValidObjectId(params.userId)) {
|
||||
throw new Error("User id is required");
|
||||
}
|
||||
|
||||
const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId });
|
||||
if (!subscription) {
|
||||
throw new Error("No subscription found for user");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const previousEndDate = subscription.endDate ? new Date(subscription.endDate) : now;
|
||||
const baseDate = previousEndDate.getTime() > now.getTime() ? previousEndDate : now;
|
||||
let newEndDate: Date;
|
||||
|
||||
if (params.mode === "add_months") {
|
||||
if (params.months !== 1 && params.months !== 2) {
|
||||
throw new Error("Months must be 1 or 2");
|
||||
}
|
||||
newEndDate = dayjs(baseDate).add(params.months, "month").toDate();
|
||||
} else if (params.mode === "set_end_date") {
|
||||
if (!params.endDate) {
|
||||
throw new Error("End date is required");
|
||||
}
|
||||
const parsedEndDate = dayjs(params.endDate);
|
||||
if (!parsedEndDate.isValid()) {
|
||||
throw new Error("End date is invalid");
|
||||
}
|
||||
newEndDate = parsedEndDate.endOf("day").toDate();
|
||||
if (newEndDate.getTime() <= now.getTime()) {
|
||||
throw new Error("End date must be in the future");
|
||||
}
|
||||
if (newEndDate.getTime() <= baseDate.getTime()) {
|
||||
throw new Error("End date must be after the current effective subscription end date");
|
||||
}
|
||||
} else {
|
||||
throw new Error("Invalid extension mode");
|
||||
}
|
||||
|
||||
subscription.endDate = newEndDate;
|
||||
subscription.isActive = true;
|
||||
if (subscription.mpStatus) {
|
||||
subscription.mpStatus = "authorized";
|
||||
}
|
||||
await subscription.save();
|
||||
|
||||
const transactionId = [
|
||||
"sysadmin_bonus",
|
||||
String(subscription.id),
|
||||
now.getTime(),
|
||||
].join(":");
|
||||
const payment = await PlanPaymentsList.createIfMissingByTransactionId({
|
||||
userId: params.userId,
|
||||
subscriptionId: String(subscription.id),
|
||||
amount: 0,
|
||||
paymentDate: now,
|
||||
paymentMethod: "sysadmin_bonus",
|
||||
status: "completed",
|
||||
transactionId,
|
||||
});
|
||||
|
||||
return {
|
||||
userId: params.userId,
|
||||
subscriptionId: String(subscription.id),
|
||||
previousEndDate,
|
||||
newEndDate,
|
||||
paymentId: String(payment.id),
|
||||
transactionId,
|
||||
};
|
||||
}
|
||||
|
||||
public async getUserSubscriptionDetails(
|
||||
params: SysAdminUserSubscriptionDetailsParams
|
||||
): Promise<SysAdminUserSubscriptionDetailsResult> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
|
||||
const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId });
|
||||
const payments = await PlanPaymentsList.find({ userId: params.userId });
|
||||
|
||||
if (!subscription) {
|
||||
return {
|
||||
userId: params.userId,
|
||||
currentSubscription: null,
|
||||
payments: payments.map((payment) => ({
|
||||
id: String(payment.id),
|
||||
subscriptionId: String(payment.subscriptionId),
|
||||
amount: payment.amount,
|
||||
paymentDate: payment.paymentDate,
|
||||
paymentMethod: payment.paymentMethod,
|
||||
status: payment.status,
|
||||
transactionId: payment.transactionId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const plan = await PlansList.plans.findOne({ _id: String(subscription.planId) });
|
||||
|
||||
return {
|
||||
userId: params.userId,
|
||||
currentSubscription: {
|
||||
id: String(subscription.id),
|
||||
plan: plan
|
||||
? {
|
||||
id: String(plan.id),
|
||||
name: plan.name,
|
||||
code: plan.code,
|
||||
price: plan.price,
|
||||
}
|
||||
: null,
|
||||
startDate: subscription.startDate,
|
||||
endDate: subscription.endDate,
|
||||
isActive: subscription.isActive,
|
||||
mpStatus: subscription.mpStatus,
|
||||
billingMonths: subscription.billingMonths,
|
||||
pendingPaymentType: subscription.pendingPaymentType,
|
||||
pendingPaymentPreferenceId: subscription.pendingPaymentPreferenceId,
|
||||
lastPaymentStatus: subscription.lastPaymentStatus,
|
||||
},
|
||||
payments: payments.map((payment) => ({
|
||||
id: String(payment.id),
|
||||
subscriptionId: String(payment.subscriptionId),
|
||||
amount: payment.amount,
|
||||
paymentDate: payment.paymentDate,
|
||||
paymentMethod: payment.paymentMethod,
|
||||
status: payment.status,
|
||||
transactionId: payment.transactionId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user