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 { DefaultUserService } from './default-user.service';
|
||||||
import { DeviceManagementService } from './device-management.service';
|
import { DeviceManagementService } from './device-management.service';
|
||||||
import { DeviceLinkingService } from './device-linking.service';
|
import { DeviceLinkingService } from './device-linking.service';
|
||||||
|
import { EntitlementService } from './entitlement.service';
|
||||||
import { OAuthIdentityService } from './oauth-identity.service';
|
import { OAuthIdentityService } from './oauth-identity.service';
|
||||||
import { OwnershipTransferService } from './ownership-transfer.service';
|
import { OwnershipTransferService } from './ownership-transfer.service';
|
||||||
import {
|
import {
|
||||||
@ -23,6 +24,7 @@ import { StorageModule } from '../storage/storage.module';
|
|||||||
DefaultUserService,
|
DefaultUserService,
|
||||||
DeviceManagementService,
|
DeviceManagementService,
|
||||||
DeviceLinkingService,
|
DeviceLinkingService,
|
||||||
|
EntitlementService,
|
||||||
OAuthIdentityService,
|
OAuthIdentityService,
|
||||||
OwnershipTransferService,
|
OwnershipTransferService,
|
||||||
BootstrapOwnerContextService,
|
BootstrapOwnerContextService,
|
||||||
@ -37,6 +39,7 @@ import { StorageModule } from '../storage/storage.module';
|
|||||||
AccountService,
|
AccountService,
|
||||||
DeviceManagementService,
|
DeviceManagementService,
|
||||||
DeviceLinkingService,
|
DeviceLinkingService,
|
||||||
|
EntitlementService,
|
||||||
OAuthIdentityService,
|
OAuthIdentityService,
|
||||||
OwnershipTransferService,
|
OwnershipTransferService,
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user