Make Arvan DNS updates opt-in and persist English user names.

Domain add/edit only upserts Arvan records when updateDns is true, and super-admin user PATCH now accepts firstNameEn and lastNameEn.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-20 11:50:57 +03:30
co-authored by Cursor
parent f61bba317e
commit 8609d8d4fd
8 changed files with 89 additions and 7 deletions
+2 -2
View File
@@ -548,7 +548,7 @@ export class BusinessAdminService {
}, },
}); });
const dnsError = await this.applyArvanDns(host); const dnsError = dto.updateDns ? await this.applyArvanDns(host) : null;
return { return {
...domain, ...domain,
@@ -648,7 +648,7 @@ export class BusinessAdminService {
}, },
}); });
const dnsError = await this.applyArvanDns(host); const dnsError = dto.updateDns ? await this.applyArvanDns(host) : null;
return { return {
...updated, ...updated,
+5
View File
@@ -10,6 +10,11 @@ export class AddDomainDto {
@IsBoolean() @IsBoolean()
isPrimary?: boolean; isPrimary?: boolean;
/** When true, upsert Meshkee tenant records in Arvan DNS. */
@IsOptional()
@IsBoolean()
updateDns?: boolean;
/** HTTPS or SSH git URL — when set, provisions storefront deploy on the websites VM. */ /** HTTPS or SSH git URL — when set, provisions storefront deploy on the websites VM. */
@IsOptional() @IsOptional()
@IsString() @IsString()
+6 -1
View File
@@ -1,4 +1,4 @@
import { IsOptional, IsString, Matches, MinLength } from 'class-validator'; import { IsBoolean, IsOptional, IsString, Matches, MinLength } from 'class-validator';
import { GIT_REPO_URL_RE } from '../../website-deploy/website-deploy.util'; import { GIT_REPO_URL_RE } from '../../website-deploy/website-deploy.util';
export class UpdateDomainDto { export class UpdateDomainDto {
@@ -6,6 +6,11 @@ export class UpdateDomainDto {
@MinLength(1) @MinLength(1)
host!: string; host!: string;
/** When true, upsert Meshkee tenant records in Arvan DNS. */
@IsOptional()
@IsBoolean()
updateDns?: boolean;
/** HTTPS or SSH git URL — when set, provisions storefront deploy on the websites VM. */ /** HTTPS or SSH git URL — when set, provisions storefront deploy on the websites VM. */
@IsOptional() @IsOptional()
@IsString() @IsString()
+2 -1
View File
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { ArvanDnsModule } from '../arvan-dns/arvan-dns.module';
import { WebsiteDeployModule } from '../website-deploy/website-deploy.module'; import { WebsiteDeployModule } from '../website-deploy/website-deploy.module';
import { DomainAdminController } from './domain-admin.controller'; import { DomainAdminController } from './domain-admin.controller';
import { DomainAdminService } from './domain-admin.service'; import { DomainAdminService } from './domain-admin.service';
@Module({ @Module({
imports: [AuthModule, ConfigModule, WebsiteDeployModule], imports: [AuthModule, ConfigModule, WebsiteDeployModule, ArvanDnsModule],
controllers: [DomainAdminController], controllers: [DomainAdminController],
providers: [DomainAdminService], providers: [DomainAdminService],
}) })
+32 -1
View File
@@ -2,12 +2,14 @@ import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
ForbiddenException, ForbiddenException,
HttpException,
Injectable, Injectable,
NotFoundException, NotFoundException,
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { ArvanDnsService } from '../arvan-dns/arvan-dns.service';
import { AuthUser } from '../auth/auth.types'; import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service'; import { PermissionsService } from '../auth/permissions.service';
import { probeTlsHost } from '../common/tls-probe'; import { probeTlsHost } from '../common/tls-probe';
@@ -39,6 +41,7 @@ export class DomainAdminService {
private readonly permissions: PermissionsService, private readonly permissions: PermissionsService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly websiteDeployAgent: WebsiteDeployAgentService, private readonly websiteDeployAgent: WebsiteDeployAgentService,
private readonly arvanDns: ArvanDnsService,
) {} ) {}
private async assertSuperAdmin(actor: AuthUser) { private async assertSuperAdmin(actor: AuthUser) {
@@ -478,13 +481,41 @@ export class DomainAdminService {
} }
} }
return this.prisma.domain.update({ const updated = await this.prisma.domain.update({
where: { id: domainId }, where: { id: domainId },
data: { data: {
host: dto.host?.trim(), host: dto.host?.trim(),
expiresAt: dto.expiresAt !== undefined ? new Date(dto.expiresAt) : undefined, expiresAt: dto.expiresAt !== undefined ? new Date(dto.expiresAt) : undefined,
}, },
}); });
const host = updated.host;
const dnsError = dto.updateDns ? await this.applyArvanDns(host) : null;
return {
...updated,
dnsError,
};
}
private async applyArvanDns(host: string): Promise<string | null> {
try {
await this.arvanDns.ensureTenantRecords(host);
return null;
} catch (err) {
if (err instanceof HttpException) {
const res = err.getResponse();
if (typeof res === 'string') return res;
if (res && typeof res === 'object' && 'message' in res) {
const message = (res as { message: unknown }).message;
return Array.isArray(message) ? message.map(String).join(', ') : String(message);
}
}
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
return err.message;
}
return 'Arvan DNS update failed';
}
} }
async disable(domainIdRaw: string, dto: DisableDomainDto, actor: AuthUser) { async disable(domainIdRaw: string, dto: DisableDomainDto, actor: AuthUser) {
@@ -1,4 +1,4 @@
import { IsDateString, IsOptional, IsString, MinLength } from 'class-validator'; import { IsBoolean, IsDateString, IsOptional, IsString, MinLength } from 'class-validator';
export class UpdateDomainAdminDto { export class UpdateDomainAdminDto {
@IsOptional() @IsOptional()
@@ -9,4 +9,9 @@ export class UpdateDomainAdminDto {
@IsOptional() @IsOptional()
@IsDateString() @IsDateString()
expiresAt?: string; expiresAt?: string;
/** When true, upsert Meshkee tenant records in Arvan DNS. */
@IsOptional()
@IsBoolean()
updateDns?: boolean;
} }
+23 -1
View File
@@ -1,16 +1,38 @@
import { IsOptional, IsString, MinLength } from 'class-validator'; import {
IsOptional,
IsString,
MaxLength,
MinLength,
ValidateIf,
} from 'class-validator';
export class UpdateUserDto { export class UpdateUserDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(100)
firstName?: string; firstName?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(100)
lastName?: string; lastName?: string;
@IsOptional()
@IsString()
@ValidateIf((_, value) => typeof value === 'string' && value.trim().length > 0)
@MinLength(2)
@MaxLength(100)
firstNameEn?: string;
@IsOptional()
@IsString()
@ValidateIf((_, value) => typeof value === 'string' && value.trim().length > 0)
@MinLength(2)
@MaxLength(100)
lastNameEn?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
email?: string; email?: string;
+13
View File
@@ -24,6 +24,8 @@ type UserListRow = {
id: bigint; id: bigint;
firstName: string | null; firstName: string | null;
lastName: string | null; lastName: string | null;
firstNameEn: string | null;
lastNameEn: string | null;
cellNumber: string; cellNumber: string;
roles: string | null; roles: string | null;
roleSlug: string | null; roleSlug: string | null;
@@ -171,7 +173,10 @@ export class UsersService {
AND ( AND (
u.first_name ILIKE ${nameLike} u.first_name ILIKE ${nameLike}
OR u.last_name ILIKE ${nameLike} OR u.last_name ILIKE ${nameLike}
OR u.first_name_en ILIKE ${nameLike}
OR u.last_name_en ILIKE ${nameLike}
OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike} OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike}
OR (COALESCE(u.first_name_en, '') || ' ' || COALESCE(u.last_name_en, '')) ILIKE ${nameLike}
) )
` : Prisma.empty} ` : Prisma.empty}
${cellNumberIlikeFilter(query.cellNumber)} ${cellNumberIlikeFilter(query.cellNumber)}
@@ -209,6 +214,8 @@ export class UsersService {
u.id AS "id", u.id AS "id",
u.first_name AS "firstName", u.first_name AS "firstName",
u.last_name AS "lastName", u.last_name AS "lastName",
u.first_name_en AS "firstNameEn",
u.last_name_en AS "lastNameEn",
u.cell_number AS "cellNumber", u.cell_number AS "cellNumber",
u.created_at AS "createdAt", u.created_at AS "createdAt",
u.is_active AS "isActive", u.is_active AS "isActive",
@@ -464,6 +471,10 @@ export class UsersService {
data: { data: {
firstName: dto.firstName?.trim(), firstName: dto.firstName?.trim(),
lastName: dto.lastName?.trim(), lastName: dto.lastName?.trim(),
firstNameEn:
dto.firstNameEn !== undefined ? dto.firstNameEn.trim() || null : undefined,
lastNameEn:
dto.lastNameEn !== undefined ? dto.lastNameEn.trim() || null : undefined,
email: dto.email !== undefined ? dto.email.trim() || null : undefined, email: dto.email !== undefined ? dto.email.trim() || null : undefined,
cellNumber: dto.cellNumber?.trim(), cellNumber: dto.cellNumber?.trim(),
}, },
@@ -473,6 +484,8 @@ export class UsersService {
id: updated.id, id: updated.id,
firstName: updated.firstName, firstName: updated.firstName,
lastName: updated.lastName, lastName: updated.lastName,
firstNameEn: updated.firstNameEn,
lastNameEn: updated.lastNameEn,
email: updated.email, email: updated.email,
cellNumber: updated.cellNumber, cellNumber: updated.cellNumber,
}; };