diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index db26007..6c52c07 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -592,4 +592,11 @@ export const enum BusinessMessage { BUSINESS_ID_REQUIRED = "شناسه کسب و کار مورد نیاز است", SLUG_REQUIRED = "اسلاگ مورد نیاز است", SLUG_MUST_BE_STRING = "اسلاگ باید یک رشته باشد", + DOMAIN_INVALID = "دامنه وارد شده معتبر نیست", + DOMAIN_UPDATED = "دامنه با موفقیت بهروزرسانی شد", + DOMAIN_VERIFICATION_INITIATED = "فرآیند تایید دامنه آغاز شد", + DOMAIN_VERIFICATION_FAILED = "تایید دامنه با خطا مواجه شد", + DOMAIN_VERIFICATION_SUCCESS = "دامنه با موفقیت تایید شد", + DOMAIN_VERIFICATION_METHOD_INVALID = "روش تایید دامنه نامعتبر است", + DOMAIN_ALREADY_VERIFIED = "این دامنه قبلا تایید شده است", } diff --git a/src/modules/businesses/DTO/business-domain.dto.ts b/src/modules/businesses/DTO/business-domain.dto.ts new file mode 100644 index 0000000..3fbf957 --- /dev/null +++ b/src/modules/businesses/DTO/business-domain.dto.ts @@ -0,0 +1,26 @@ +import { IsEnum, IsNotEmpty, IsOptional, IsString, Matches } from "class-validator"; + +export enum DomainVerificationMethod { + DNS_TXT = "DNS_TXT", + FILE = "FILE", + META_TAG = "META_TAG", +} + +export class UpdateBusinessDomainDto { + @IsNotEmpty() + @IsString() + @Matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, { + message: "Domain must be a valid domain name (e.g., example.com)", + }) + domain: string; +} + +export class VerifyDomainDto { + @IsNotEmpty() + @IsEnum(DomainVerificationMethod) + method: DomainVerificationMethod; + + @IsOptional() + @IsString() + businessId?: string; +} diff --git a/src/modules/businesses/businesses.controller.ts b/src/modules/businesses/businesses.controller.ts index bd75659..9ca4653 100644 --- a/src/modules/businesses/businesses.controller.ts +++ b/src/modules/businesses/businesses.controller.ts @@ -1,14 +1,51 @@ -import { Controller, Get, Param } from "@nestjs/common"; +import { Body, Controller, Get, Param, Post } from "@nestjs/common"; +import { UpdateBusinessDomainDto, VerifyDomainDto } from "./DTO/business-domain.dto"; import { BusinessSlugParamDto } from "./DTO/business-slug-param.dto"; +import { Business } from "./entities/business.entity"; import { BusinessesService } from "./services/businesses.service"; +import { DomainVerificationService } from "./services/domain-verification.service"; +import { BusinessDec } from "../../common/decorators/business.decorator"; @Controller("business") export class BusinessesController { - constructor(private readonly businessesService: BusinessesService) {} + constructor( + private readonly businessesService: BusinessesService, + private readonly domainVerificationService: DomainVerificationService, + ) {} @Get("slug/:slug") async getBusinessBySlug(@Param() params: BusinessSlugParamDto) { return this.businessesService.getBusinessBySlug(params.slug); } + + @Post() + async updateBusinessDomain(@BusinessDec() business: Business, @Body() domainDto: UpdateBusinessDomainDto) { + return this.domainVerificationService.updateBusinessDomain(business.id, domainDto); + } + + @Get("status") + async getDomainStatus(@BusinessDec() business: Business) { + return this.domainVerificationService.getDomainVerificationStatus(business.id); + } + + @Post("verify") + async verifyDomain(@BusinessDec() business: Business, @Body() verifyDto: VerifyDomainDto) { + return this.domainVerificationService.initiateVerification(business.id, verifyDto); + } + + @Post("verify/check") + async checkVerification(@BusinessDec() business: Business) { + return this.domainVerificationService.verifyDomain(business.id); + } + + @Post("admin/:businessId/verify") + async adminVerifyDomain(@Param("businessId") businessId: string, @Body() verifyDto: VerifyDomainDto) { + return this.domainVerificationService.initiateVerification(businessId, verifyDto); + } + + @Post("admin/:businessId/verify/check") + async adminCheckVerification(@Param("businessId") businessId: string) { + return this.domainVerificationService.verifyDomain(businessId); + } } diff --git a/src/modules/businesses/businesses.module.ts b/src/modules/businesses/businesses.module.ts index 7c1932a..7c74210 100644 --- a/src/modules/businesses/businesses.module.ts +++ b/src/modules/businesses/businesses.module.ts @@ -7,6 +7,7 @@ import { SUBSCRIPTIONS } from "./constant"; import { Business } from "./entities/business.entity"; import { BusinessProvisioningProcessor } from "./queue/business-provisioning.processor"; import { BusinessesService } from "./services/businesses.service"; +import { DomainVerificationService } from "./services/domain-verification.service"; @Module({ imports: [ @@ -16,7 +17,7 @@ import { BusinessesService } from "./services/businesses.service"; prefix: SUBSCRIPTIONS.PROVISIONING_QUEUE_PREFIX, }), ], - providers: [BusinessesService, BusinessProvisioningProcessor], + providers: [BusinessesService, BusinessProvisioningProcessor, DomainVerificationService], exports: [BusinessesService], controllers: [BusinessesController], }) diff --git a/src/modules/businesses/entities/business.entity.ts b/src/modules/businesses/entities/business.entity.ts index 78d350b..0d6c262 100644 --- a/src/modules/businesses/entities/business.entity.ts +++ b/src/modules/businesses/entities/business.entity.ts @@ -1,4 +1,4 @@ -import { Cascade, Collection, Entity, EntityRepositoryType, OneToMany, Property, Unique } from "@mikro-orm/core"; +import { Cascade, Collection, Entity, EntityRepositoryType, Enum, OneToMany, Opt, Property, Unique } from "@mikro-orm/core"; import { BaseEntity } from "../../../common/entities/base.entity"; import { Announcement } from "../../announcements/entities/announcement.entity"; @@ -9,6 +9,7 @@ import { Invoice } from "../../invoices/entities/invoice.entity"; import { TicketCategory } from "../../tickets/entities/ticket-category.entity"; import { Ticket } from "../../tickets/entities/ticket.entity"; import { User } from "../../users/entities/user.entity"; +import { DomainVerificationMethod } from "../DTO/business-domain.dto"; import { BusinessRepository } from "../repositories/business.repository"; @Entity({ repository: () => BusinessRepository }) @@ -26,6 +27,18 @@ export class Business extends BaseEntity { @Property({ type: "varchar", length: 255, nullable: true }) domain?: string; + @Property({ type: "boolean", default: false }) + isDomainVerified: boolean & Opt; + + @Enum({ items: () => DomainVerificationMethod, nullable: true }) + domainVerificationMethod?: DomainVerificationMethod; + + @Property({ type: "varchar", length: 255, nullable: true }) + domainVerificationToken?: string; + + @Property({ type: "datetime", nullable: true }) + domainVerifiedAt?: Date; + @Property({ type: "varchar", length: 255, nullable: true }) logoUrl?: string; diff --git a/src/modules/businesses/services/businesses.service.ts b/src/modules/businesses/services/businesses.service.ts index 26d011b..578911b 100644 --- a/src/modules/businesses/services/businesses.service.ts +++ b/src/modules/businesses/services/businesses.service.ts @@ -20,4 +20,15 @@ export class BusinessesService { return { business }; } + + async getBusinessById(id: string) { + const business = await this.businessRepository.findOne({ id, deletedAt: null }); + if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND); + + return business; + } + + async flushChanges() { + await this.businessRepository.getEntityManager().flush(); + } } diff --git a/src/modules/businesses/services/domain-verification.service.ts b/src/modules/businesses/services/domain-verification.service.ts new file mode 100644 index 0000000..f5a24a3 --- /dev/null +++ b/src/modules/businesses/services/domain-verification.service.ts @@ -0,0 +1,311 @@ +import * as crypto from "node:crypto"; +import * as dns from "node:dns"; +import { promisify } from "node:util"; + +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import axios from "axios"; + +import { BusinessesService } from "./businesses.service"; +import { BusinessMessage } from "../../../common/enums/message.enum"; +import { DomainVerificationMethod, UpdateBusinessDomainDto, VerifyDomainDto } from "../DTO/business-domain.dto"; + +@Injectable() +export class DomainVerificationService { + private readonly logger = new Logger(DomainVerificationService.name); + private readonly resolveTxt = promisify(dns.resolveTxt); + + constructor(private readonly businessesService: BusinessesService) {} + + /** + * Update business domain + */ + async updateBusinessDomain(businessId: string, domainDto: UpdateBusinessDomainDto) { + const business = await this.businessesService.getBusinessById(businessId); + + // Domain hasn't changed, check if it's already verified + if (business.domain === domainDto.domain && business.isDomainVerified) { + return { + message: BusinessMessage.DOMAIN_ALREADY_VERIFIED, + domain: business.domain, + isVerified: business.isDomainVerified, + }; + } + + // Generate verification token if domain changed or not verified + const verificationToken = this.generateVerificationToken(); + + // Update the domain and verification data + business.domain = domainDto.domain; + business.isDomainVerified = false; + business.domainVerificationToken = verificationToken; + business.domainVerifiedAt = undefined; + business.domainVerificationMethod = undefined; + + await this.businessesService.flushChanges(); + + return { + message: BusinessMessage.DOMAIN_UPDATED, + domain: business.domain, + isVerified: business.isDomainVerified, + verificationMethods: this.getVerificationInstructions(business.domain, verificationToken), + }; + } + + /** + * Initiate domain verification with specific method + */ + async initiateVerification(businessId: string, verifyDto: VerifyDomainDto) { + const business = await this.businessesService.getBusinessById(businessId); + + if (!business.domain) { + throw new BadRequestException(BusinessMessage.DOMAIN_INVALID); + } + + if (business.isDomainVerified) { + return { + message: BusinessMessage.DOMAIN_ALREADY_VERIFIED, + domain: business.domain, + isVerified: true, + verifiedAt: business.domainVerifiedAt, + }; + } + + // If no token exists, generate one + if (!business.domainVerificationToken) { + business.domainVerificationToken = this.generateVerificationToken(); + await this.businessesService.flushChanges(); + } + + // Update verification method + business.domainVerificationMethod = verifyDto.method; + await this.businessesService.flushChanges(); + + return { + message: BusinessMessage.DOMAIN_VERIFICATION_INITIATED, + domain: business.domain, + method: verifyDto.method, + instructions: this.getVerificationInstructions(business.domain, business.domainVerificationToken)[verifyDto.method], + }; + } + + /** + * Check domain verification status + */ + async verifyDomain(businessId: string) { + const business = await this.businessesService.getBusinessById(businessId); + + if (!business.domain || !business.domainVerificationToken || !business.domainVerificationMethod) { + throw new BadRequestException(BusinessMessage.DOMAIN_VERIFICATION_METHOD_INVALID); + } + + if (business.isDomainVerified) { + return { + message: BusinessMessage.DOMAIN_ALREADY_VERIFIED, + domain: business.domain, + isVerified: true, + verifiedAt: business.domainVerifiedAt, + }; + } + + try { + let isVerified = false; + + switch (business.domainVerificationMethod) { + case DomainVerificationMethod.DNS_TXT: + isVerified = await this.verifyDnsTxtRecord(business.domain, business.domainVerificationToken); + break; + case DomainVerificationMethod.FILE: + isVerified = await this.verifyFileMethod(business.domain, business.domainVerificationToken); + break; + case DomainVerificationMethod.META_TAG: + isVerified = await this.verifyMetaTagMethod(business.domain, business.domainVerificationToken); + break; + default: + throw new BadRequestException(BusinessMessage.DOMAIN_VERIFICATION_METHOD_INVALID); + } + + if (isVerified) { + business.isDomainVerified = true; + business.domainVerifiedAt = new Date(); + await this.businessesService.flushChanges(); + + return { + message: BusinessMessage.DOMAIN_VERIFICATION_SUCCESS, + domain: business.domain, + isVerified: true, + verifiedAt: business.domainVerifiedAt, + }; + } else { + const verificationMethod = business.domainVerificationMethod; + return { + message: BusinessMessage.DOMAIN_VERIFICATION_FAILED, + domain: business.domain, + isVerified: false, + instructions: this.getVerificationInstructions(business.domain, business.domainVerificationToken)[verificationMethod], + }; + } + } catch (error: unknown) { + this.logger.error(`Domain verification failed: ${error instanceof Error ? error.message : String(error)}`); + throw new BadRequestException(BusinessMessage.DOMAIN_VERIFICATION_FAILED); + } + } + + /** + * Get domain verification status + */ + async getDomainVerificationStatus(businessId: string) { + const business = await this.businessesService.getBusinessById(businessId); + + if (!business.domain) { + return { + hasDomain: false, + }; + } + + return { + hasDomain: true, + domain: business.domain, + isVerified: business.isDomainVerified, + verificationMethod: business.domainVerificationMethod, + verifiedAt: business.domainVerifiedAt, + instructions: business.domainVerificationToken ? this.getVerificationInstructions(business.domain, business.domainVerificationToken) : null, + }; + } + + /** + * Generate a random verification token + */ + private generateVerificationToken(): string { + return `dzone-verify-${crypto.randomBytes(16).toString("hex")}`; + } + + /** + * Get verification instructions for all methods + */ + private getVerificationInstructions(domain: string, token: string) { + return { + [DomainVerificationMethod.DNS_TXT]: { + title: "Verify using DNS TXT record", + description: "Add a TXT record to your domain with the following values:", + host: "@", // or "_dzone-verification" + type: "TXT", + value: token, + }, + [DomainVerificationMethod.FILE]: { + title: "Verify using a file", + description: "Create a file at the root of your domain with the following properties:", + path: `/.well-known/${token}.html`, + content: token, + fullUrl: `https://${domain}/.well-known/${token}.html`, + }, + [DomainVerificationMethod.META_TAG]: { + title: "Verify using meta tag", + description: "Add the following meta tag to the
section of your homepage:", + tag: ``, + }, + }; + } + + /** + * Verify domain using DNS TXT record + */ + private async verifyDnsTxtRecord(domain: string, token: string): Promise