diff --git a/docker/Dockerfile b/docker/Dockerfile index 264386f7..b38232b9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -66,7 +66,9 @@ ARG COOKIE_SECRET ARG LOGTO_M2M_APP_ID ARG LOGTO_M2M_APP_SECRET ARG LOGTO_MANAGEMENT_API +ARG LOGTO_TESTNET_ROLE_ID ARG LOGTO_DEFAULT_ROLE_ID +ARG LOGTO_MAINNET_ROLE_ID ARG LOGTO_WEBHOOK_SECRET ARG LOG_LEVEL=info @@ -115,7 +117,9 @@ ENV COOKIE_SECRET ${COOKIE_SECRET} ENV LOGTO_M2M_APP_ID ${LOGTO_M2M_APP_ID} ENV LOGTO_M2M_APP_SECRET ${LOGTO_M2M_APP_SECRET} ENV LOGTO_MANAGEMENT_API ${LOGTO_MANAGEMENT_API} +ENV LOGTO_TESTNET_ROLE_ID ${LOGTO_TESTNET_ROLE_ID} ENV LOGTO_DEFAULT_ROLE_ID ${LOGTO_DEFAULT_ROLE_ID} +ENV LOGTO_MAINNET_ROLE_ID ${LOGTO_MAINNET_ROLE_ID} ENV LOGTO_WEBHOOK_SECRET ${LOGTO_WEBHOOK_SECRET} ENV LOG_LEVEL ${LOG_LEVEL} diff --git a/example.env b/example.env index 73160f8c..b88b9452 100644 --- a/example.env +++ b/example.env @@ -22,8 +22,10 @@ LOGTO_M2M_APP_ID="aaaa...ddddd" LOGTO_M2M_APP_SECRET="aaaa...ddddd" LOGTO_MANAGEMENT_API="https://default.logto.app/api" LOGTO_DEFAULT_RESOURCE_URL="http://localhost:3000" -LOGTO_DEFAULT_ROLE_ID="sdf...sdf" LOGTO_WEBHOOK_SECRET="sdf...sdf" +LOGTO_DEFAULT_ROLE_ID="sdlkfj" +LOGTO_TESTNET_ROLE_ID="asdf" +LOGTO_MAINNET_ROLE_ID="sdklfj" COOKIE_SECRET="sdf...sdf" # Faucet settings diff --git a/jest.config.cjs b/jest.config.cjs index 6a6f5684..d40154b7 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -16,4 +16,5 @@ module.exports = { collectCoverageFrom: ['src/**/*.{ts,js}'], moduleDirectories: ['node_modules', 'src'], testEnvironment: 'node', + testTimeout: 10 * 1000, // 10s }; diff --git a/package-lock.json b/package-lock.json index a1564881..700653e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cheqd/studio", - "version": "3.1.0-develop.1", + "version": "3.1.0-develop.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cheqd/studio", - "version": "3.1.0-develop.1", + "version": "3.1.0-develop.2", "license": "Apache-2.0", "dependencies": { "@cheqd/did-provider-cheqd": "^4.1.1", @@ -105,7 +105,7 @@ "uint8arrays": "^5.1.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=20.11.0" } }, "node_modules/@adraffy/ens-normalize": { diff --git a/src/controllers/api/account.ts b/src/controllers/api/account.ts index c1200adc..4b8a8dce 100644 --- a/src/controllers/api/account.ts +++ b/src/controllers/api/account.ts @@ -7,7 +7,6 @@ import { FaucetHelper } from '../../helpers/faucet.js'; import { StatusCodes } from 'http-status-codes'; import { LogToWebHook } from '../../middleware/hook.js'; import { UserService } from '../../services/api/user.js'; -import { RoleService } from '../../services/api/role.js'; import { PaymentAccountService } from '../../services/api/payment-account.js'; import type { CustomerEntity } from '../../database/entities/customer.entity.js'; import type { PaymentAccountEntity } from '../../database/entities/payment.account.entity.js'; @@ -25,6 +24,13 @@ import type { ISubmitOperation, ISubmitStripeCustomerCreateData } from '../../se import * as dotenv from 'dotenv'; import { validate } from '../validator/decorator.js'; import { SupportedKeyTypes } from '@veramo/utils'; +import { SubscriptionService } from '../../services/admin/subscription.js'; +import Stripe from 'stripe'; +import { RoleService } from '../../services/api/role.js'; +import { SafeAPIResponse } from '../../types/common.js'; +import { RoleEntity } from '../../database/entities/role.entity.js'; +import { getStripeObjectKey } from '../../utils/index.js'; +import type { SupportedPlanTypes } from '../../types/admin.js'; dotenv.config(); export class AccountController { @@ -57,7 +63,7 @@ export class AccountController { * 500: * $ref: '#/components/schemas/InternalError' */ - public async get(request: Request, response: Response) { + public async get(_request: Request, response: Response) { try { if (!response.locals.customer) { // It's not ok, seems like there no any customer assigned to the user yet @@ -145,22 +151,20 @@ export class AccountController { // For now we keep temporary 1-1 relation between user and customer // So the flow is: // 1. Get LogTo user id from request body - // 2. Check if such row exists in the DB - // 2.1. If no - create it - // 3. If yes - check that there is customer associated with such user - // 3.1. If no: - // 3.1.1. Create customer - // 3.1.2. Assign customer to the user - - // 4. Check is paymentAccount exists for the customer - // 4.1. If no - create it - - // 5. Assign default role on LogTo - // 5.1 Get user's roles - // 5.2 If list of roles is empty and the user is not suspended - assign default role - // 6. Create custom_data and update the userInfo (send it to the LogTo) - // 6.1 If custom_data is empty - create it - // 7. Check the token balance for Testnet account + // 2. Check if there is customer associated with such user + // 2.1 If not, create a new customer entity + // 3. Assign role to user + // 3.1 If user already has a subscription, assign role based on subscription + // 3.2 Else assign the default "Portal" role + // 4. If no customer is associated with the user (from point 2), create customer + // 4.1 Assign customer to the user + // 5. Create User + // 6. Check is paymentAccount exists for the customer + // 6.1. If no - create it + // 7. Create custom_data and update the userInfo (send it to the LogTo) + // 8. If custom_data is empty - create it + // 9. Check the token balance for Testnet account + // 10. Add the Stripe account to the Customer let paymentAccount: PaymentAccountEntity | null; @@ -175,20 +179,24 @@ export class AccountController { // use email as name, because "name" is unique in the current db setup. const logToName = request.body.user.name || logToUserEmail; - const defaultRole = await RoleService.instance.getDefaultRole(); - if (!defaultRole) { - return response.status(StatusCodes.BAD_REQUEST).json({ - error: 'Default role is not set on Credential Service side', - } satisfies UnsuccessfulResponseBody); - } + const stripe = response.locals.stripe as Stripe; + const logToHelper = new LogToHelper(); // 2. Check if such row exists in the DB - let [user, [customer]] = await Promise.all([ + // eslint-disable-next-line prefer-const + let [userEntity, [customerEntity], logtoHelperSetup] = await Promise.all([ UserService.instance.get(logToUserId), CustomerService.instance.find({ email: logToUserEmail }), + logToHelper.setup(), ]); - if (!user) { - // 2.1. If no - create customer first + if (logtoHelperSetup.status !== StatusCodes.OK) { + return response.status(StatusCodes.SERVICE_UNAVAILABLE).json({ + error: logtoHelperSetup.error, + } satisfies UnsuccessfulResponseBody); + } + + if (!userEntity) { + // 2. If no - create customer first // Cause for now we assume only 1-1 connection between user and customer // We think here that if no user row - no customer also, cause customer should be created before user // Even if customer was created before for such user but the process was interruted somehow - we need to create it again @@ -196,9 +204,9 @@ export class AccountController { // 2.1.1. Create customer // I’m setting the "name" field to an empty string on the current CustomerEntity because it is non-nullable. // we will populate the customer's "name" field using the response from the Stripe account creation in account-submitter.ts. - if (!customer) { - customer = (await CustomerService.instance.create(logToName, logToUserEmail)) as CustomerEntity; - if (!customer) { + if (!customerEntity) { + customerEntity = (await CustomerService.instance.create(logToName, logToUserEmail)) as CustomerEntity; + if (!customerEntity) { return response.status(StatusCodes.BAD_REQUEST).json({ error: 'User is not found in database: Customer was not created', } satisfies UnsuccessfulResponseBody); @@ -207,16 +215,31 @@ export class AccountController { await eventTracker.notify({ message: EventTracker.compileBasicNotification( 'User was not found in database: Customer with customerId: ' + - customer.customerId + + customerEntity.customerId + ' was created' ), severity: 'info', }); } - // 2.2. Create user - user = await UserService.instance.create(logToUserId, customer, defaultRole); - if (!user) { + // 3 Assign role to user + let role: RoleEntity | null = null; + const logtoRoleSync = await syncLogtoUserRoles(logToHelper, stripe, request, logToUserId, customerEntity); + if (!logtoRoleSync.success) { + role = await RoleService.instance.getDefaultRole(); + } else { + role = await RoleService.instance.findOne({ name: logtoRoleSync.data }); + } + + if (!role) { + return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: `Internal error: No logto role found`, + } satisfies UnsuccessfulResponseBody); + } + + // 2.3. Create user + userEntity = await UserService.instance.create(logToUserId, customerEntity, role); + if (!userEntity) { return response.status(StatusCodes.BAD_REQUEST).json({ error: 'User is not found in database: User was not created', } satisfies UnsuccessfulResponseBody); @@ -224,18 +247,16 @@ export class AccountController { // Notify await eventTracker.notify({ message: EventTracker.compileBasicNotification( - 'User was not found in database: User with userId: ' + user.logToId + ' was created' + 'User was not found in database: User with userId: ' + userEntity.logToId + ' was created' ), severity: 'info', }); } - // 3. If yes - check that there is customer associated with such user - if (!user.customer) { - // 3.1. If no: - // 3.1.1. Create customer - customer = (await CustomerService.instance.create(logToUserEmail)) as CustomerEntity; - if (!customer) { + //4. Check if there is customer associated with such user + if (!userEntity.customer) { + customerEntity = (await CustomerService.instance.create(logToUserEmail)) as CustomerEntity; + if (!customerEntity) { return response.status(StatusCodes.BAD_REQUEST).json({ error: 'User exists in database: Customer was not created', } satisfies UnsuccessfulResponseBody); @@ -243,34 +264,34 @@ export class AccountController { // Notify await eventTracker.notify({ message: EventTracker.compileBasicNotification( - 'User exists in database: Customer with customerId: ' + customer.customerId + ' was created' + 'User exists in database: Customer with customerId: ' + customerEntity.customerId + ' was created' ), severity: 'info', }); - // 3.1.2. Assign customer to the user - user.customer = customer; - await UserService.instance.update(user.logToId, customer); + //4.1. Assign customer to the user + userEntity.customer = customerEntity; + await UserService.instance.update(userEntity.logToId, customerEntity); } else { - customer = user.customer; + customerEntity = userEntity.customer; // this time user exists, so notify stripe account should be created. - if (process.env.STRIPE_ENABLED === 'true' && !customer.paymentProviderId) { + if (process.env.STRIPE_ENABLED === 'true' && !customerEntity.paymentProviderId) { eventTracker.submit({ operation: OperationNameEnum.STRIPE_ACCOUNT_CREATE, data: { - name: customer.name, - email: customer.email, - customerId: customer.customerId, + name: customerEntity.name, + email: customerEntity.email, + customerId: customerEntity.customerId, } satisfies ISubmitStripeCustomerCreateData, } satisfies ISubmitOperation); } } - // 4. Check is paymentAccount exists for the customer - const accounts = await PaymentAccountService.instance.find({ customer }); + // 6. Check is paymentAccount exists for the customer + const accounts = await PaymentAccountService.instance.find({ customer: customerEntity }); if (accounts.length === 0) { - const key = await new IdentityServiceStrategySetup(customer.customerId).agent.createKey( + const key = await new IdentityServiceStrategySetup(customerEntity.customerId).agent.createKey( SupportedKeyTypes.Secp256k1, - customer + customerEntity ); if (!key) { return response.status(StatusCodes.BAD_REQUEST).json({ @@ -280,7 +301,7 @@ export class AccountController { paymentAccount = (await PaymentAccountService.instance.create( CheqdNetwork.Testnet, true, - customer, + customerEntity, key )) as PaymentAccountEntity; if (!paymentAccount) { @@ -301,55 +322,21 @@ export class AccountController { paymentAccount = accounts[0]; } - const logToHelper = new LogToHelper(); - const _r = await logToHelper.setup(); - if (_r.status !== StatusCodes.OK) { - return response.status(StatusCodes.BAD_GATEWAY).json({ - error: _r.error, - } satisfies UnsuccessfulResponseBody); - } - // 5. Assign default role on LogTo - // 5.1 Get user's roles + // 7. Assign default role on LogTo + const customDataFromLogTo = await logToHelper.getCustomData(userEntity.logToId); - const roles = await logToHelper.getRolesForUser(logToUserId); - if (roles.status !== StatusCodes.OK) { - return response.status(StatusCodes.BAD_GATEWAY).json({ - error: roles.error, - } satisfies UnsuccessfulResponseBody); - } - - // 5.2 If list of roles is empty and the user is not suspended - assign default role - if (roles.data.length === 0 && !LogToWebHook.isUserSuspended(request)) { - const _r = await logToHelper.setDefaultRoleForUser(user.logToId); - if (_r.status !== StatusCodes.OK) { - return response.status(StatusCodes.BAD_GATEWAY).json({ - error: _r.error, - } satisfies UnsuccessfulResponseBody); - } - - // Notify - await eventTracker.notify({ - message: EventTracker.compileBasicNotification( - `Default role with id: ${process.env.LOGTO_DEFAULT_ROLE_ID} was assigned to user with id: ${user.logToId}` - ), - severity: 'info', - }); - } - - const customDataFromLogTo = await logToHelper.getCustomData(user.logToId); - - // 6. Create custom_data and update the userInfo (send it to the LogTo) + // 8. Create custom_data and update the userInfo (send it to the LogTo) if (Object.keys(customDataFromLogTo.data).length === 0 && paymentAccount.address) { const customData = { customer: { - id: customer.customerId, - name: customer.name, + id: customerEntity.customerId, + name: customerEntity.name, }, paymentAccount: { address: paymentAccount.address, }, }; - const _r = await logToHelper.updateCustomData(user.logToId, customData); + const _r = await logToHelper.updateCustomData(userEntity.logToId, customData); if (_r.status !== 200) { return response.status(_r.status).json({ error: _r.error, @@ -357,7 +344,7 @@ export class AccountController { } } - // 7. Check the token balance for Testnet account + // 9. Check the token balance for Testnet account if (paymentAccount.address && process.env.ENABLE_ACCOUNT_TOPUP === 'true') { const balances = await checkBalance(paymentAccount.address, process.env.TESTNET_RPC_URL); const balance = balances[0]; @@ -379,14 +366,14 @@ export class AccountController { } } - // 8. Add the Stripe account to the Customer - if (process.env.STRIPE_ENABLED === 'true' && !customer.paymentProviderId) { + // 10. Add the Stripe account to the Customer + if (process.env.STRIPE_ENABLED === 'true' && !customerEntity.paymentProviderId) { eventTracker.submit({ operation: OperationNameEnum.STRIPE_ACCOUNT_CREATE, data: { - name: customer.name, - email: customer.email, - customerId: customer.customerId, + name: customerEntity.name, + email: customerEntity.email, + customerId: customerEntity.customerId, } satisfies ISubmitStripeCustomerCreateData, } satisfies ISubmitOperation); } @@ -514,3 +501,58 @@ export class AccountController { } } } + +async function syncLogtoUserRoles( + logToHelper: LogToHelper, + stripe: Stripe, + request: Request, + logToUserId: string, + customer: CustomerEntity +): Promise> { + // 3.1 If user already has a subscription, assign role based on subscription + // 3.2 Else assign the default "Portal" role + if (!LogToWebHook.isUserSuspended(request)) { + const subscription = await SubscriptionService.instance.findCurrent(customer); + if (!subscription) { + return { + success: false, + error: `Logto role assigned failed: No active subscription found for customer with id: ${customer.customerId}`, + status: 400, + }; + } + + const stripeSubscription = await stripe.subscriptions.retrieve(subscription.subscriptionId); + const stripeProduct = await stripe.products.retrieve( + getStripeObjectKey(stripeSubscription.items.data[0].plan.product) + ); + + const logtoRoleName = stripeProduct.name.toLowerCase() as SupportedPlanTypes; + const roleResponse = await logToHelper.assignCustomerPlanRoles(logToUserId, logtoRoleName); + if (roleResponse.status !== StatusCodes.OK) { + return { + success: false, + status: roleResponse.status, + error: roleResponse.error, + }; + } + + await eventTracker.notify({ + message: EventTracker.compileBasicNotification( + `${stripeProduct.name} role was assigned to user with id: ${logToUserId}` + ), + severity: 'info', + }); + + return { + success: true, + status: 200, + data: logtoRoleName, + }; + } + + return { + success: false, + error: `Logto role not assigned: User with id ${logToUserId} is suspended`, + status: 409, + }; +} diff --git a/src/middleware/auth/logto-helper.ts b/src/middleware/auth/logto-helper.ts index 1ce51138..8138f4ea 100644 --- a/src/middleware/auth/logto-helper.ts +++ b/src/middleware/auth/logto-helper.ts @@ -5,6 +5,8 @@ import * as dotenv from 'dotenv'; import type { IOAuthProvider } from './oauth/abstract.js'; import { OAuthProvider } from './oauth/abstract.js'; import { EventTracker, eventTracker } from '../../services/track/tracker.js'; +import { env } from 'node:process'; +import { SupportedPlanTypes } from '../../types/admin.js'; dotenv.config(); export class LogToHelper extends OAuthProvider implements IOAuthProvider { @@ -81,37 +83,6 @@ export class LogToHelper extends OAuthProvider implements IOAuthProvider { return this.allResourceWithNames; } - public async setDefaultRoleForUser(userId: string): Promise { - const roles = await this.getRolesForUser(userId); - if (roles.status !== StatusCodes.OK) { - return this.returnError(StatusCodes.BAD_GATEWAY, roles.error); - } - // Check that default role is set - for (const role of roles.data) { - if (role.id === process.env.LOGTO_DEFAULT_ROLE_ID) { - return this.returnOk(roles.data); - } - } - // Assign a default role to a user - return await this.assignDefaultRoleForUser(userId, process.env.LOGTO_DEFAULT_ROLE_ID); - } - - public async setDefaultRoleForApp(appId: string): Promise { - const roles = await this.getRolesForUser(appId); - if (roles.status !== StatusCodes.OK) { - return this.returnError(StatusCodes.BAD_GATEWAY, roles.error); - } - // Check that default role is set - for (const role of roles.data) { - if (role.id === process.env.LOGTO_DEFAULT_ROLE_ID) { - return this.returnOk(roles.data); - } - } - - // Assign a default role to a user - return await this.assignDefaultRoleForApp(appId, process.env.LOGTO_DEFAULT_ROLE_ID); - } - private returnOk(data: any): ICommonErrorResponse { return { status: StatusCodes.OK, @@ -287,58 +258,101 @@ export class LogToHelper extends OAuthProvider implements IOAuthProvider { } } - private async getRoleInfo(roleId: string): Promise { - const uri = new URL(`/api/roles/${roleId}`, process.env.LOGTO_ENDPOINT); - try { - return await this.getToLogto(uri, 'GET'); - } catch (err) { - return this.returnError(StatusCodes.BAD_GATEWAY, `getRoleInfo ${err}`); - } - } - - private async assignDefaultRoleForUser(userId: string, roleId: string): Promise { - const userInfo = await this.getUserInfo(userId); - const uri = new URL(`/api/users/${userId}/roles`, process.env.LOGTO_ENDPOINT); + async assignCustomerPlanRoles(userId: string, planType: SupportedPlanTypes): Promise { + const [userInfo, userRoles] = await Promise.all([this.getUserInfo(userId), this.getRolesForUser(userId)]); if (userInfo.status !== StatusCodes.OK) { - return this.returnError( - StatusCodes.BAD_GATEWAY, - `Could not fetch the info about role with roleId ${roleId}` - ); + return this.returnError(userInfo.status, 'Could not fetch the info about the user'); } // Means that user exists - if (userInfo.data.isSuspended === 'true') { + if (userInfo.data.isSuspended) { return this.returnError(StatusCodes.FORBIDDEN, 'User is suspended'); } - // Means it's not suspended - const role = await this.getRoleInfo(roleId); - if (role.status !== StatusCodes.OK) { - return this.returnError( - StatusCodes.BAD_GATEWAY, - `Could not fetch the info about user with userId ${userId} because of error from authority server: ${role.error}` - ); + + if (userRoles.status !== StatusCodes.OK) { + return this.returnError(userRoles.status, 'Could not fetch the info about user roles'); + } + + const assignedRoleIds = (userRoles.data as { id: string; name: string }[]).map((role) => role.id); + + // INFO: Context + // We currently have two plans, Build and Test. + // All of our users get a "Portal" role which let's them work with our Studio Portal (UI) + // Test plan lets you operate with our Testnet so we assign the "Testnet" role + // Build plan lets you work with our Testnet and Mainnet, so we assign "Testnet" and "Mainnet" roles + // "build" is the superset of all the roles (testnet + mainnet) + const buildPlanRoleIds = [env.LOGTO_MAINNET_ROLE_ID.trim(), env.LOGTO_TESTNET_ROLE_ID.trim()]; + const testPlanRoleId = env.LOGTO_TESTNET_ROLE_ID.trim(); + const hasDefaultPortalRole = assignedRoleIds.findIndex((roleId) => roleId === env.LOGTO_DEFAULT_ROLE_ID) != -1; + + const planRoleIds = hasDefaultPortalRole ? [] : [env.LOGTO_DEFAULT_ROLE_ID.trim()]; + if (planType === SupportedPlanTypes.Build) { + const buildRoleIsAssigned = buildPlanRoleIds.every((roleId) => assignedRoleIds.includes(roleId)); + if (buildRoleIsAssigned) { + return { + status: 201, + error: '', + data: { + message: `${planType} plan role was successfully assigned to the user`, + }, + }; + } + + const billingPlanRoleIds = buildPlanRoleIds.filter((id) => !assignedRoleIds.includes(id)); + planRoleIds.push(...billingPlanRoleIds); + } else if (planType === SupportedPlanTypes.Test) { + // "build" plan is a superset of "test" plan + + // check the user has mainnet role, remove it + const mainnetRoleId = assignedRoleIds.find((roleId) => roleId === buildPlanRoleIds[0]); + if (mainnetRoleId) { + await this.removeLogtoRoleFromUser(userId, mainnetRoleId); + } + + const testnetRoleId = assignedRoleIds.find((roleId) => roleId === testPlanRoleId); + if (testnetRoleId) { + return { + status: 201, + error: '', + data: { + message: `${planType} plan role was successfully assigned to the user`, + }, + }; + } + + planRoleIds.push(testPlanRoleId); } - // Such role exists + try { + const uri = new URL(`/api/users/${userId}/roles`, process.env.LOGTO_ENDPOINT); const body = { - roleIds: [roleId], + roleIds: planRoleIds, }; + return await this.postToLogto(uri, body, { 'Content-Type': 'application/json' }); } catch (err) { return this.returnError(StatusCodes.BAD_GATEWAY, `getRolesForUser ${err}`); } } - private async assignDefaultRoleForApp(appId: string, roleId: string): Promise { - const uri = new URL(`/api/applications/${appId}/roles`, process.env.LOGTO_ENDPOINT); - // Such role exists + async removeLogtoRoleFromUser(userId: string, roleId: string) { + const uri = new URL(`/api/users/${userId}/roles/${roleId}`, process.env.LOGTO_ENDPOINT); + try { - const body = { - roleIds: [roleId], - }; - return await this.postToLogto(uri, body, { 'Content-Type': 'application/json' }); + const response = await fetch(uri, { + method: 'DELETE', + headers: { + Authorization: 'Bearer ' + (await this.getM2MToken()), + }, + }); + if (response.status === StatusCodes.NO_CONTENT) { + return this.returnOk({}); + } + + const err = await response.text(); + return this.returnError(response.status, err); } catch (err) { - return this.returnError(StatusCodes.BAD_GATEWAY, `getRolesForUser ${err}`); + return this.returnError(StatusCodes.INTERNAL_SERVER_ERROR, `removeUserRole: ${err}`); } } @@ -381,7 +395,7 @@ export class LogToHelper extends OAuthProvider implements IOAuthProvider { try { return await this.getToLogto(uri, 'GET'); } catch (err) { - return this.returnError(StatusCodes.BAD_GATEWAY, `getUserInfo ${err}`); + return this.returnError(StatusCodes.SERVICE_UNAVAILABLE, `getUserInfo ${err}`); } } diff --git a/src/services/api/role.ts b/src/services/api/role.ts index 46ec24fc..66c471f1 100644 --- a/src/services/api/role.ts +++ b/src/services/api/role.ts @@ -4,6 +4,7 @@ import { Connection } from '../../database/connection/connection.js'; import * as dotenv from 'dotenv'; import { RoleEntity } from '../../database/entities/role.entity.js'; +import { DefaultStudioRoleName } from '../../types/constants.js'; dotenv.config(); export class RoleService { @@ -57,6 +58,6 @@ export class RoleService { } public getDefaultRole() { - return this.roleRepository.findOneBy({ name: 'default' }); + return this.roleRepository.findOneBy({ name: DefaultStudioRoleName }); } } diff --git a/src/services/api/user.ts b/src/services/api/user.ts index fe068e82..3edd5bdf 100644 --- a/src/services/api/user.ts +++ b/src/services/api/user.ts @@ -19,7 +19,7 @@ export class UserService { } public async create(logToId: string, customer: CustomerEntity, role: RoleEntity): Promise { - if (await this.isExist({ logToId: logToId })) { + if (await this.isExist({ logToId })) { throw new Error(`Cannot create a new user since the user with same logToId ${logToId} already exists`); } const userEntity = new UserEntity(logToId, customer, role); diff --git a/src/services/track/admin/subscription-submitter.ts b/src/services/track/admin/subscription-submitter.ts index 7bd24271..6984b3b7 100644 --- a/src/services/track/admin/subscription-submitter.ts +++ b/src/services/track/admin/subscription-submitter.ts @@ -8,12 +8,18 @@ import type { ISubmitOperation, ISubmitSubscriptionData } from '../submitter.js' import { EventTracker } from '../tracker.js'; import type { IObserver } from '../types.js'; import type { FindOptionsWhere } from 'typeorm'; +import { LogToHelper } from '../../../middleware/auth/logto-helper.js'; +import { StatusCodes } from 'http-status-codes'; +import type { SupportedPlanTypes } from '../../../types/admin.js'; +import { UserService } from '../../api/user.js'; export class SubscriptionSubmitter implements IObserver { private emitter: EventEmitter; + private readonly stripe: Stripe; constructor(emitter: EventEmitter) { this.emitter = emitter; + this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY); } notify(notifyMessage: INotifyMessage): void { @@ -36,14 +42,116 @@ export class SubscriptionSubmitter implements IObserver { } } + private async handleCustomerRoleAssignment( + operation: ISubmitOperation, + logToHelper: LogToHelper, + userLogtoId: string, + productName: string + ) { + const roleAssignmentResponse = await logToHelper.assignCustomerPlanRoles( + userLogtoId, + productName.toLowerCase() as SupportedPlanTypes + ); + if (roleAssignmentResponse.status !== 201) { + this.notify({ + message: EventTracker.compileBasicNotification( + `Failed to assign roles to user for planType ${productName}: ${roleAssignmentResponse.error}`, + operation.operation + ), + severity: 'error', + }); + return; + } + + this.notify({ + message: EventTracker.compileBasicNotification( + `${productName} plan assigned to user with logtoId ${userLogtoId}`, + operation.operation + ), + severity: 'info', + }); + } + + private async handleCustomerRoleRemoval(operation: ISubmitOperation, logto: LogToHelper, userLogtoId: string) { + const responses = await Promise.allSettled([ + logto.removeLogtoRoleFromUser(userLogtoId, process.env.LOGTO_TESTNET_ROLE_ID.trim()), + logto.removeLogtoRoleFromUser(userLogtoId, process.env.LOGTO_MAINNET_ROLE_ID.trim()), + ]); + + const allRolesRemoved = responses.every((r) => r.status === 'fulfilled' && r.value.status === StatusCodes.OK); + if (allRolesRemoved) { + this.notify({ + message: EventTracker.compileBasicNotification( + `Roles have been removed successfully for user with id: ${userLogtoId}`, + operation.operation + ), + severity: 'info', + }); + return; + } + + for (const resp of responses) { + if (resp.status === 'rejected' || resp.value.status !== StatusCodes.OK) { + const errMsg = resp.status === 'rejected' ? (resp.reason as Error).message : resp.value.error; + this.notify({ + message: EventTracker.compileBasicNotification( + `Role removal error: ${errMsg}`, + operation.operation + ), + severity: 'error', + }); + } + } + } + + private async syncLogtoRoles(operation: ISubmitOperation, customerId: string, productName: string) { + const logToHelper = new LogToHelper(); + const setupResp = await logToHelper.setup(); + if (setupResp.status !== StatusCodes.OK) { + this.notify({ + message: EventTracker.compileBasicNotification( + `Logto client initialisation failed: ${setupResp.error}`, + operation.operation + ), + severity: 'error', + }); + + return; + } + + const user = await UserService.instance.userRepository.findOne({ where: { customer: { customerId } } }); + + if (user) { + switch (operation.operation) { + case OperationNameEnum.SUBSCRIPTION_CREATE: + case OperationNameEnum.SUBSCRIPTION_UPDATE: + this.handleCustomerRoleAssignment(operation, logToHelper, user.logToId, productName); + return; + case OperationNameEnum.SUBSCRIPTION_CANCEL: + this.handleCustomerRoleRemoval(operation, logToHelper, user.logToId); + return; + } + } + + this.notify({ + message: EventTracker.compileBasicNotification( + `Role assignment failed: No user found with customerId: ${customerId}`, + operation.operation + ), + severity: 'error', + }); + } + async submitSubscriptionCreate(operation: ISubmitOperation): Promise { - const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const data = operation.data as ISubmitSubscriptionData; let customer: CustomerEntity | undefined = operation.options?.customer; + try { + const [product, stripeCustomer] = await Promise.all([ + this.stripe.products.retrieve(data.productId), + this.stripe.customers.retrieve(data.paymentProviderId), + ]); if (!customer) { - const stripeCustomer = await stripe.customers.retrieve(data.paymentProviderId); - const whereClause: FindOptionsWhere[] = [{ paymentProviderId: data.paymentProviderId }]; // we add an additional "OR" check in case that a customer was created locally with email and no paymentProviderId if (!stripeCustomer.deleted && stripeCustomer.email) { @@ -53,7 +161,6 @@ export class SubscriptionSubmitter implements IObserver { const customers = await CustomerService.instance.customerRepository.find({ where: whereClause, }); - if (customers.length === 0) { this.notify({ message: EventTracker.compileBasicNotification( @@ -106,6 +213,8 @@ export class SubscriptionSubmitter implements IObserver { }); } + await this.syncLogtoRoles(operation, customer.customerId, product.name); + this.notify({ message: EventTracker.compileBasicNotification( `Subscription created with id: ${data.subscriptionId}.`, @@ -126,6 +235,7 @@ export class SubscriptionSubmitter implements IObserver { async submitSubscriptionUpdate(operation: ISubmitOperation): Promise { const data = operation.data as ISubmitSubscriptionData; + try { const subscription = await SubscriptionService.instance.update( data.subscriptionId, @@ -145,6 +255,15 @@ export class SubscriptionSubmitter implements IObserver { }); } + const [customer, product] = await Promise.all([ + CustomerService.instance.findbyPaymentProviderId(data.paymentProviderId), + this.stripe.products.retrieve(data.productId), + ]); + + if (customer) { + await this.syncLogtoRoles(operation, customer.customerId, product.name); + } + this.notify({ message: EventTracker.compileBasicNotification( `Subscription updated with id: ${data.subscriptionId}.`, @@ -165,6 +284,7 @@ export class SubscriptionSubmitter implements IObserver { async submitSubscriptionCancel(operation: ISubmitOperation): Promise { const data = operation.data as ISubmitSubscriptionData; + try { const subscription = await SubscriptionService.instance.update(data.subscriptionId, data.status); if (!subscription) { @@ -177,6 +297,11 @@ export class SubscriptionSubmitter implements IObserver { }); } + const customer = await CustomerService.instance.findbyPaymentProviderId(data.paymentProviderId); + if (customer) { + this.syncLogtoRoles(operation, customer.customerId, ''); + } + this.notify({ message: EventTracker.compileBasicNotification( `Subscription canceled with id: ${data.subscriptionId}.`, diff --git a/src/services/track/helpers.ts b/src/services/track/helpers.ts index 638f4718..0bc81b18 100644 --- a/src/services/track/helpers.ts +++ b/src/services/track/helpers.ts @@ -10,6 +10,7 @@ import type { import type Stripe from 'stripe'; import type { ISubmitData, ISubmitOperation } from './submitter.js'; import type { ISubmitOptions } from './types.js'; +import { getStripeObjectKey } from '../../utils/index.js'; export function isResourceTrack(data: TrackData): data is IResourceTrack { return Object.keys(data).length === 2 && (data as IResourceTrack).resource !== undefined; @@ -46,6 +47,8 @@ export function buildSubmitOperation(subscription: Stripe.Subscription, name: st currentPeriodEnd: new Date(subscription.current_period_end * 1000), trialStart: subscription.trial_start ? new Date(subscription.trial_start * 1000) : undefined, trialEnd: subscription.trial_end ? new Date(subscription.trial_end * 1000) : undefined, + productId: getStripeObjectKey(subscription.items.data[0].plan.product), + priceId: getStripeObjectKey(subscription.items.data[0].plan.id), } satisfies ISubmitData, options, } satisfies ISubmitOperation; diff --git a/src/services/track/submitter.ts b/src/services/track/submitter.ts index 207660e2..74b8ea08 100644 --- a/src/services/track/submitter.ts +++ b/src/services/track/submitter.ts @@ -17,6 +17,8 @@ export interface ISubmitSubscriptionData { trialStart?: Date; trialEnd?: Date; subscriptionId: string; + productId: string; + priceId: string; } export interface ISubmitOperation { diff --git a/src/types/admin.ts b/src/types/admin.ts index beb67616..0333668a 100644 --- a/src/types/admin.ts +++ b/src/types/admin.ts @@ -176,3 +176,8 @@ export type AdminOrganisationUpdateUnsuccessfulResponseBody = UnsuccessfulRespon // Utils export type PaymentBehavior = Stripe.SubscriptionCreateParams.PaymentBehavior; + +export enum SupportedPlanTypes { + Build = 'build', + Test = 'test', +} diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 00000000..d2b38d3d --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,13 @@ +export type APISuccessResponse = { + success: true; + status: number; + data: Output; +}; + +export type APIErrorResponse = { + success: false; + status: number; + error: Input; +}; + +export type SafeAPIResponse = APISuccessResponse | APIErrorResponse; diff --git a/src/types/constants.ts b/src/types/constants.ts index b84e8d2a..55ec96a2 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -150,3 +150,4 @@ export const JWT_PROOF_TYPE = 'JwtProof2020'; export const StatusList2021Entry = 'StatusList2021Entry'; export const JSONLD_PROOF_TYPES = ['Ed25519Signature2018', 'Ed25519Signature2020']; export const DEFAULT_PAGINATION_LIST_LIMIT = 10; +export const DefaultStudioRoleName = 'default' as const; diff --git a/src/types/environment.d.ts b/src/types/environment.d.ts index 3e4d3065..481741a8 100644 --- a/src/types/environment.d.ts +++ b/src/types/environment.d.ts @@ -29,7 +29,9 @@ declare global { LOGTO_M2M_APP_ID: string; LOGTO_M2M_APP_SECRET: string; LOGTO_MANAGEMENT_API: string; + LOGTO_TESTNET_ROLE_ID: string; LOGTO_DEFAULT_ROLE_ID: string; + LOGTO_MAINNET_ROLE_ID: string; LOGTO_WEBHOOK_SECRET: string; // Authentication diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..d784f36b --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,11 @@ +export function getStripeObjectKey(input: string | { id: string } | null) { + if (!input) { + return ''; + } + + if (typeof input === 'string') { + return input; + } + + return input.id; +}