Add entitlement runtime
This commit is contained in:
parent
13e91cb764
commit
aa7d85b2e0
405
backend/src/modules/users/entitlement.service.spec.ts
Normal file
405
backend/src/modules/users/entitlement.service.spec.ts
Normal file
@ -0,0 +1,405 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { SubscriptionPlan, SubscriptionStatus } from '@prisma/client';
|
||||
import {
|
||||
ENTITLEMENT_KEYS,
|
||||
ENTITLEMENT_SOURCES,
|
||||
EntitlementService,
|
||||
} from './entitlement.service';
|
||||
|
||||
describe('EntitlementService', () => {
|
||||
const now = new Date('2026-06-28T12:00:00.000Z');
|
||||
const future = new Date('2026-07-28T12:00:00.000Z');
|
||||
const past = new Date('2026-06-27T12:00:00.000Z');
|
||||
|
||||
const buildService = () => {
|
||||
const subscriptions = new Map<string, any>();
|
||||
const entitlements = new Map<string, any>();
|
||||
|
||||
const entitlementId = (userId: string, entitlementKey: string) =>
|
||||
`${userId}:${entitlementKey}`;
|
||||
|
||||
const userSubscription = {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
const subscription = subscriptions.get(where.userId);
|
||||
if (!subscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
currentPeriodEnd: subscription.currentPeriodEnd,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const userEntitlement = {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
const key = where.userId_entitlementKey;
|
||||
const entitlement = entitlements.get(
|
||||
entitlementId(key.userId, key.entitlementKey),
|
||||
);
|
||||
|
||||
return entitlement ? { granted: entitlement.granted } : null;
|
||||
}),
|
||||
findMany: jest.fn().mockImplementation(async ({ where }) =>
|
||||
Array.from(entitlements.values())
|
||||
.filter((entitlement) => entitlement.userId === where.userId)
|
||||
.sort((left, right) =>
|
||||
left.entitlementKey.localeCompare(right.entitlementKey),
|
||||
)
|
||||
.map(
|
||||
({ entitlementKey, granted, source, createdAt, updatedAt }) => ({
|
||||
entitlementKey,
|
||||
granted,
|
||||
source,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}),
|
||||
),
|
||||
),
|
||||
upsert: jest
|
||||
.fn()
|
||||
.mockImplementation(async ({ where, update, create }) => {
|
||||
const key = where.userId_entitlementKey;
|
||||
const id = entitlementId(key.userId, key.entitlementKey);
|
||||
const existing = entitlements.get(id);
|
||||
const timestamp = new Date(now);
|
||||
const entitlement = existing
|
||||
? { ...existing, ...update, updatedAt: timestamp }
|
||||
: {
|
||||
id: randomUUID(),
|
||||
...create,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
entitlements.set(id, entitlement);
|
||||
|
||||
return {
|
||||
entitlementKey: entitlement.entitlementKey,
|
||||
granted: entitlement.granted,
|
||||
source: entitlement.source,
|
||||
createdAt: entitlement.createdAt,
|
||||
updatedAt: entitlement.updatedAt,
|
||||
};
|
||||
}),
|
||||
updateMany: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||
let count = 0;
|
||||
|
||||
for (const [id, entitlement] of entitlements) {
|
||||
if (
|
||||
entitlement.userId === where.userId &&
|
||||
where.entitlementKey.in.includes(entitlement.entitlementKey) &&
|
||||
entitlement.source === where.source
|
||||
) {
|
||||
entitlements.set(id, {
|
||||
...entitlement,
|
||||
...data,
|
||||
updatedAt: new Date(now),
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { count };
|
||||
}),
|
||||
createMany: jest.fn().mockImplementation(async ({ data }) => {
|
||||
let count = 0;
|
||||
|
||||
for (const create of data) {
|
||||
const id = entitlementId(create.userId, create.entitlementKey);
|
||||
if (entitlements.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entitlements.set(id, {
|
||||
id: randomUUID(),
|
||||
...create,
|
||||
createdAt: new Date(now),
|
||||
updatedAt: new Date(now),
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
|
||||
return { count };
|
||||
}),
|
||||
};
|
||||
|
||||
const prismaService = {
|
||||
userSubscription,
|
||||
userEntitlement,
|
||||
} as any;
|
||||
|
||||
return {
|
||||
service: new EntitlementService(prismaService),
|
||||
subscriptions,
|
||||
entitlements,
|
||||
prismaService,
|
||||
setSubscription: (
|
||||
userId: string,
|
||||
status: SubscriptionStatus,
|
||||
plan: SubscriptionPlan,
|
||||
currentPeriodEnd = future,
|
||||
) => {
|
||||
subscriptions.set(userId, {
|
||||
userId,
|
||||
status,
|
||||
plan,
|
||||
currentPeriodEnd,
|
||||
providerCustomerId: 'provider-id-must-not-affect-plan',
|
||||
providerSubscriptionId: 'provider-subscription-id',
|
||||
});
|
||||
},
|
||||
setEntitlement: (
|
||||
userId: string,
|
||||
entitlementKey: string,
|
||||
granted: boolean,
|
||||
source: string,
|
||||
) => {
|
||||
entitlements.set(entitlementId(userId, entitlementKey), {
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
entitlementKey,
|
||||
granted,
|
||||
source,
|
||||
createdAt: new Date(now),
|
||||
updatedAt: new Date(now),
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it('returns FREE when the user has no subscription', async () => {
|
||||
const { service } = buildService();
|
||||
|
||||
await expect(service.getEffectivePlan(randomUUID(), now)).resolves.toBe(
|
||||
SubscriptionPlan.FREE,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns PRO for an ACTIVE PRO subscription in its current period', async () => {
|
||||
const { service, setSubscription } = buildService();
|
||||
const userId = randomUUID();
|
||||
setSubscription(
|
||||
userId,
|
||||
SubscriptionStatus.ACTIVE,
|
||||
SubscriptionPlan.PRO,
|
||||
);
|
||||
|
||||
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
|
||||
SubscriptionPlan.PRO,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns PRO for a current TRIAL', async () => {
|
||||
const { service, setSubscription } = buildService();
|
||||
const userId = randomUUID();
|
||||
setSubscription(
|
||||
userId,
|
||||
SubscriptionStatus.TRIAL,
|
||||
SubscriptionPlan.FREE,
|
||||
);
|
||||
|
||||
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
|
||||
SubscriptionPlan.PRO,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not infer PRO from provider IDs on an ACTIVE FREE subscription', async () => {
|
||||
const { service, setSubscription } = buildService();
|
||||
const userId = randomUUID();
|
||||
setSubscription(
|
||||
userId,
|
||||
SubscriptionStatus.ACTIVE,
|
||||
SubscriptionPlan.FREE,
|
||||
);
|
||||
|
||||
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
|
||||
SubscriptionPlan.FREE,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns FREE for an ACTIVE PRO subscription past its period end', async () => {
|
||||
const { service, setSubscription } = buildService();
|
||||
const userId = randomUUID();
|
||||
setSubscription(
|
||||
userId,
|
||||
SubscriptionStatus.ACTIVE,
|
||||
SubscriptionPlan.PRO,
|
||||
past,
|
||||
);
|
||||
|
||||
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
|
||||
SubscriptionPlan.FREE,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
SubscriptionStatus.PAST_DUE,
|
||||
SubscriptionStatus.CANCELLED,
|
||||
SubscriptionStatus.EXPIRED,
|
||||
])('returns FREE for a current %s subscription', async (status) => {
|
||||
const { service, setSubscription } = buildService();
|
||||
const userId = randomUUID();
|
||||
setSubscription(userId, status, SubscriptionPlan.PRO);
|
||||
|
||||
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
|
||||
SubscriptionPlan.FREE,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows a directly granted entitlement on the FREE plan', async () => {
|
||||
const { service } = buildService();
|
||||
const userId = randomUUID();
|
||||
|
||||
await service.grantEntitlement(
|
||||
userId,
|
||||
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.hasEntitlement(
|
||||
userId,
|
||||
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
|
||||
now,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('allows Pro feature access from the effective plan before a sync', async () => {
|
||||
const { service, setSubscription } = buildService();
|
||||
const userId = randomUUID();
|
||||
setSubscription(
|
||||
userId,
|
||||
SubscriptionStatus.ACTIVE,
|
||||
SubscriptionPlan.PRO,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.hasEntitlement(userId, ENTITLEMENT_KEYS.PRO_LIBRARY, now),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('denies a revoked entitlement on the FREE plan', async () => {
|
||||
const { service } = buildService();
|
||||
const userId = randomUUID();
|
||||
|
||||
await service.revokeEntitlement(
|
||||
userId,
|
||||
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.hasEntitlement(
|
||||
userId,
|
||||
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
|
||||
now,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('grants all Pro entitlements when syncing an effective PRO plan', async () => {
|
||||
const { service, setSubscription } = buildService();
|
||||
const userId = randomUUID();
|
||||
setSubscription(
|
||||
userId,
|
||||
SubscriptionStatus.ACTIVE,
|
||||
SubscriptionPlan.PRO,
|
||||
);
|
||||
|
||||
const result = await service.syncEntitlementsForSubscription(userId, now);
|
||||
|
||||
expect(result).toHaveLength(EntitlementService.proEntitlementKeys.length);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining(
|
||||
EntitlementService.proEntitlementKeys.map((entitlementKey) =>
|
||||
expect.objectContaining({
|
||||
entitlementKey,
|
||||
granted: true,
|
||||
source: ENTITLEMENT_SOURCES.SUBSCRIPTION,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes only subscription-sourced Pro entitlements when syncing FREE', async () => {
|
||||
const { service, setEntitlement } = buildService();
|
||||
const userId = randomUUID();
|
||||
setEntitlement(
|
||||
userId,
|
||||
ENTITLEMENT_KEYS.PRO_LIBRARY,
|
||||
true,
|
||||
ENTITLEMENT_SOURCES.SUBSCRIPTION,
|
||||
);
|
||||
setEntitlement(
|
||||
userId,
|
||||
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
|
||||
true,
|
||||
ENTITLEMENT_SOURCES.MANUAL,
|
||||
);
|
||||
|
||||
const result = await service.syncEntitlementsForSubscription(userId, now);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
entitlementKey: ENTITLEMENT_KEYS.PRO_LIBRARY,
|
||||
granted: false,
|
||||
source: ENTITLEMENT_SOURCES.SUBSCRIPTION,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
entitlementKey: ENTITLEMENT_KEYS.SMART_DOWNLOADS,
|
||||
granted: true,
|
||||
source: ENTITLEMENT_SOURCES.MANUAL,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not create duplicate entitlements across repeated syncs', async () => {
|
||||
const { service, setSubscription, entitlements } = buildService();
|
||||
const userId = randomUUID();
|
||||
setSubscription(
|
||||
userId,
|
||||
SubscriptionStatus.TRIAL,
|
||||
SubscriptionPlan.PRO,
|
||||
);
|
||||
|
||||
await service.syncEntitlementsForSubscription(userId, now);
|
||||
await service.syncEntitlementsForSubscription(userId, now);
|
||||
|
||||
expect(
|
||||
Array.from(entitlements.values()).filter(
|
||||
(entitlement) => entitlement.userId === userId,
|
||||
),
|
||||
).toHaveLength(EntitlementService.proEntitlementKeys.length);
|
||||
});
|
||||
|
||||
it('keeps entitlement reads, checks, and syncs scoped to the requested user', async () => {
|
||||
const { service, setEntitlement } = buildService();
|
||||
const userId = randomUUID();
|
||||
const otherUserId = randomUUID();
|
||||
setEntitlement(
|
||||
otherUserId,
|
||||
ENTITLEMENT_KEYS.PRIORITY_SYNC,
|
||||
true,
|
||||
ENTITLEMENT_SOURCES.MANUAL,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.hasEntitlement(userId, ENTITLEMENT_KEYS.PRIORITY_SYNC, now),
|
||||
).resolves.toBe(false);
|
||||
await expect(service.getUserEntitlements(userId)).resolves.toEqual([]);
|
||||
|
||||
await service.syncEntitlementsForSubscription(userId, now);
|
||||
|
||||
await expect(service.getUserEntitlements(otherUserId)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
entitlementKey: ENTITLEMENT_KEYS.PRIORITY_SYNC,
|
||||
granted: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
221
backend/src/modules/users/entitlement.service.ts
Normal file
221
backend/src/modules/users/entitlement.service.ts
Normal file
@ -0,0 +1,221 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SubscriptionPlan, SubscriptionStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
|
||||
export const ENTITLEMENT_KEYS = {
|
||||
PRO_LIBRARY: 'PRO_LIBRARY',
|
||||
UNLIMITED_DEVICES: 'UNLIMITED_DEVICES',
|
||||
ADVANCED_AUDIO_ANALYSIS: 'ADVANCED_AUDIO_ANALYSIS',
|
||||
SMART_DOWNLOADS: 'SMART_DOWNLOADS',
|
||||
PRIORITY_SYNC: 'PRIORITY_SYNC',
|
||||
} as const;
|
||||
|
||||
export type EntitlementKey =
|
||||
(typeof ENTITLEMENT_KEYS)[keyof typeof ENTITLEMENT_KEYS];
|
||||
|
||||
export const ENTITLEMENT_SOURCES = {
|
||||
SUBSCRIPTION: 'SUBSCRIPTION',
|
||||
MANUAL: 'MANUAL',
|
||||
SYSTEM: 'SYSTEM',
|
||||
} as const;
|
||||
|
||||
export type EntitlementSource =
|
||||
(typeof ENTITLEMENT_SOURCES)[keyof typeof ENTITLEMENT_SOURCES];
|
||||
|
||||
export interface UserEntitlementSummary {
|
||||
entitlementKey: string;
|
||||
granted: boolean;
|
||||
source: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const PRO_ENTITLEMENT_KEYS = Object.values(ENTITLEMENT_KEYS);
|
||||
|
||||
@Injectable()
|
||||
export class EntitlementService {
|
||||
static readonly proEntitlementKeys: readonly EntitlementKey[] =
|
||||
PRO_ENTITLEMENT_KEYS;
|
||||
|
||||
constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
async getEffectivePlan(
|
||||
userId: string,
|
||||
now = new Date(),
|
||||
): Promise<SubscriptionPlan> {
|
||||
const subscription = await this.prismaService.userSubscription.findUnique({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
plan: true,
|
||||
status: true,
|
||||
currentPeriodEnd: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!subscription || subscription.currentPeriodEnd <= now) {
|
||||
return SubscriptionPlan.FREE;
|
||||
}
|
||||
|
||||
if (subscription.status === SubscriptionStatus.TRIAL) {
|
||||
return SubscriptionPlan.PRO;
|
||||
}
|
||||
|
||||
if (
|
||||
subscription.status === SubscriptionStatus.ACTIVE &&
|
||||
subscription.plan === SubscriptionPlan.PRO
|
||||
) {
|
||||
return SubscriptionPlan.PRO;
|
||||
}
|
||||
|
||||
return SubscriptionPlan.FREE;
|
||||
}
|
||||
|
||||
async getUserEntitlements(
|
||||
userId: string,
|
||||
): Promise<UserEntitlementSummary[]> {
|
||||
return this.prismaService.userEntitlement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
orderBy: {
|
||||
entitlementKey: 'asc',
|
||||
},
|
||||
select: {
|
||||
entitlementKey: true,
|
||||
granted: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async hasEntitlement(
|
||||
userId: string,
|
||||
entitlementKey: EntitlementKey,
|
||||
now = new Date(),
|
||||
): Promise<boolean> {
|
||||
const [directEntitlement, effectivePlan] = await Promise.all([
|
||||
this.prismaService.userEntitlement.findUnique({
|
||||
where: {
|
||||
userId_entitlementKey: {
|
||||
userId,
|
||||
entitlementKey,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
granted: true,
|
||||
},
|
||||
}),
|
||||
this.getEffectivePlan(userId, now),
|
||||
]);
|
||||
|
||||
if (directEntitlement?.granted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
effectivePlan === SubscriptionPlan.PRO &&
|
||||
PRO_ENTITLEMENT_KEYS.includes(entitlementKey)
|
||||
);
|
||||
}
|
||||
|
||||
async grantEntitlement(
|
||||
userId: string,
|
||||
entitlementKey: EntitlementKey,
|
||||
source: EntitlementSource = ENTITLEMENT_SOURCES.MANUAL,
|
||||
): Promise<UserEntitlementSummary> {
|
||||
return this.setDirectEntitlement(userId, entitlementKey, true, source);
|
||||
}
|
||||
|
||||
async revokeEntitlement(
|
||||
userId: string,
|
||||
entitlementKey: EntitlementKey,
|
||||
source: EntitlementSource = ENTITLEMENT_SOURCES.MANUAL,
|
||||
): Promise<UserEntitlementSummary> {
|
||||
return this.setDirectEntitlement(userId, entitlementKey, false, source);
|
||||
}
|
||||
|
||||
async syncEntitlementsForSubscription(
|
||||
userId: string,
|
||||
now = new Date(),
|
||||
): Promise<UserEntitlementSummary[]> {
|
||||
const effectivePlan = await this.getEffectivePlan(userId, now);
|
||||
const subscriptionSource = ENTITLEMENT_SOURCES.SUBSCRIPTION;
|
||||
|
||||
if (effectivePlan === SubscriptionPlan.PRO) {
|
||||
await this.prismaService.userEntitlement.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
entitlementKey: {
|
||||
in: PRO_ENTITLEMENT_KEYS,
|
||||
},
|
||||
source: subscriptionSource,
|
||||
},
|
||||
data: {
|
||||
granted: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prismaService.userEntitlement.createMany({
|
||||
data: PRO_ENTITLEMENT_KEYS.map((entitlementKey) => ({
|
||||
userId,
|
||||
entitlementKey,
|
||||
granted: true,
|
||||
source: subscriptionSource,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
} else {
|
||||
await this.prismaService.userEntitlement.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
entitlementKey: {
|
||||
in: PRO_ENTITLEMENT_KEYS,
|
||||
},
|
||||
source: subscriptionSource,
|
||||
},
|
||||
data: {
|
||||
granted: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.getUserEntitlements(userId);
|
||||
}
|
||||
|
||||
private async setDirectEntitlement(
|
||||
userId: string,
|
||||
entitlementKey: EntitlementKey,
|
||||
granted: boolean,
|
||||
source: EntitlementSource,
|
||||
): Promise<UserEntitlementSummary> {
|
||||
return this.prismaService.userEntitlement.upsert({
|
||||
where: {
|
||||
userId_entitlementKey: {
|
||||
userId,
|
||||
entitlementKey,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
granted,
|
||||
source,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
entitlementKey,
|
||||
granted,
|
||||
source,
|
||||
},
|
||||
select: {
|
||||
entitlementKey: true,
|
||||
granted: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { AccountService } from './account.service';
|
||||
import { DefaultUserService } from './default-user.service';
|
||||
import { DeviceManagementService } from './device-management.service';
|
||||
import { DeviceLinkingService } from './device-linking.service';
|
||||
import { EntitlementService } from './entitlement.service';
|
||||
import { OAuthIdentityService } from './oauth-identity.service';
|
||||
import { OwnershipTransferService } from './ownership-transfer.service';
|
||||
import {
|
||||
@ -23,6 +24,7 @@ import { StorageModule } from '../storage/storage.module';
|
||||
DefaultUserService,
|
||||
DeviceManagementService,
|
||||
DeviceLinkingService,
|
||||
EntitlementService,
|
||||
OAuthIdentityService,
|
||||
OwnershipTransferService,
|
||||
BootstrapOwnerContextService,
|
||||
@ -37,6 +39,7 @@ import { StorageModule } from '../storage/storage.module';
|
||||
AccountService,
|
||||
DeviceManagementService,
|
||||
DeviceLinkingService,
|
||||
EntitlementService,
|
||||
OAuthIdentityService,
|
||||
OwnershipTransferService,
|
||||
],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user