feat: StorageService S3/MinIO, POST /users, invoice upload, portal dashboard §08 statuses + notifications
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from "@nestjs/common";
|
||||
@@ -44,3 +45,15 @@ export class NotificationsController {
|
||||
return this.svc.seedDefaultTemplates(req.user.tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /notifications — bandeja de entrada del usuario */
|
||||
@Controller("notifications")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class NotificationsUserController {
|
||||
constructor(private readonly svc: NotificationsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Request() req: any, @Query("limit") limit?: string) {
|
||||
return this.svc.findByUser(req.user.id, limit ? Number(limit) : 20);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
import { NotificationsController } from "./notifications.controller";
|
||||
import { NotificationsController, NotificationsUserController } from "./notifications.controller";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { IntegrationsModule } from "../integrations/integrations.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, IntegrationsModule],
|
||||
controllers: [NotificationsController],
|
||||
controllers: [NotificationsController, NotificationsUserController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
|
||||
@@ -3,27 +3,21 @@ import {
|
||||
UseGuards, UseInterceptors, UploadedFiles,
|
||||
} from "@nestjs/common";
|
||||
import { FilesInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { extname, join } from "path";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
import { memoryStorage } from "multer";
|
||||
import { extname } from "path";
|
||||
import { PackagesService } from "./packages.service";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { CreatePackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
|
||||
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
@Controller("packages")
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class PackagesController {
|
||||
private readonly apiUrl: string;
|
||||
|
||||
constructor(
|
||||
private svc: PackagesService,
|
||||
private config: ConfigService,
|
||||
) {
|
||||
this.apiUrl = this.config.get("API_URL", "http://localhost:3001");
|
||||
}
|
||||
private storage: StorageService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@@ -72,23 +66,13 @@ export class PackagesController {
|
||||
return this.svc.verifyPackage(id, dto, user.id);
|
||||
}
|
||||
|
||||
/** Bodega: upload photos via multipart form (doc §10 step 4) */
|
||||
/** Bodega: upload photos via multipart form — stored via StorageService (doc §10 step 4) */
|
||||
@Post(":id/photos")
|
||||
@Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
@UseInterceptors(
|
||||
FilesInterceptor("photos", 10, {
|
||||
storage: diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const pkgId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const dir = join(process.cwd(), "uploads", "packages", pkgId);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
cb(null, `${randomUUID()}${extname(file.originalname)}`);
|
||||
},
|
||||
}),
|
||||
fileFilter: (req, file, cb) => {
|
||||
storage: memoryStorage(),
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = /jpg|jpeg|png|gif|webp/;
|
||||
cb(null, allowed.test(extname(file.originalname).toLowerCase()));
|
||||
},
|
||||
@@ -100,9 +84,15 @@ export class PackagesController {
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: any,
|
||||
): Promise<any> {
|
||||
const baseUrl = this.apiUrl;
|
||||
const photoUrls = (files || []).map(
|
||||
f => `${baseUrl}/uploads/packages/${id}/${f.filename}`,
|
||||
const photoUrls = await Promise.all(
|
||||
(files || []).map(f =>
|
||||
this.storage.saveFile(
|
||||
`packages/${id}`,
|
||||
f.originalname,
|
||||
f.buffer,
|
||||
f.mimetype,
|
||||
),
|
||||
),
|
||||
);
|
||||
return this.svc.addPhotos(id, photoUrls, user.id);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { PackagesController } from "./packages.controller";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
import { StorageModule } from "../storage/storage.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ConfigModule, NotificationsModule],
|
||||
imports: [PrismaModule, ConfigModule, NotificationsModule, StorageModule],
|
||||
providers: [PackagesService],
|
||||
controllers: [PackagesController],
|
||||
exports: [PackagesService],
|
||||
|
||||
@@ -15,6 +15,10 @@ export class CreatePreAlertDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
vendorTracking?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
estimatedArrival?: string;
|
||||
}
|
||||
|
||||
export class UpdatePreAlertStatusDto {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
Controller, Get, Post, Patch, Delete, Body, Param, UseGuards,
|
||||
UseInterceptors, UploadedFile,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { memoryStorage } from "multer";
|
||||
import { PreAlertsService } from "./pre-alerts.service";
|
||||
import { CreatePreAlertDto, UpdatePreAlertStatusDto } from "./dto/pre-alert.dto";
|
||||
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
|
||||
@Controller("pre-alerts")
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class PreAlertsController {
|
||||
constructor(private svc: PreAlertsService) {}
|
||||
constructor(
|
||||
private svc: PreAlertsService,
|
||||
private storage: StorageService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
findAll(@CurrentUser() user: any) {
|
||||
@@ -20,6 +29,34 @@ export class PreAlertsController {
|
||||
return this.svc.create(dto, user);
|
||||
}
|
||||
|
||||
/** Upload invoice PDF/image for a pre-alert (doc §07 / §09) */
|
||||
@Post(":id/invoice")
|
||||
@Roles("CLIENTE")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("invoice", {
|
||||
storage: memoryStorage(),
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = /pdf|jpg|jpeg|png|webp/;
|
||||
const ext = file.originalname.split(".").pop()?.toLowerCase() ?? "";
|
||||
cb(null, allowed.test(ext));
|
||||
},
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
||||
}),
|
||||
)
|
||||
async uploadInvoice(
|
||||
@Param("id") id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: any,
|
||||
): Promise<any> {
|
||||
const url = await this.storage.saveFile(
|
||||
"invoices",
|
||||
file.originalname,
|
||||
file.buffer,
|
||||
file.mimetype,
|
||||
);
|
||||
return this.svc.attachInvoice(id, url, user);
|
||||
}
|
||||
|
||||
@Patch(":id/status")
|
||||
@Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
updateStatus(@Param("id") id: string, @Body() dto: UpdatePreAlertStatusDto): Promise<any> {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PreAlertsController } from "./pre-alerts.controller";
|
||||
import { PreAlertsService } from "./pre-alerts.service";
|
||||
import { StorageModule } from "../storage/storage.module";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
|
||||
@Module({ controllers: [PreAlertsController], providers: [PreAlertsService] })
|
||||
@Module({
|
||||
imports: [PrismaModule, StorageModule],
|
||||
controllers: [PreAlertsController],
|
||||
providers: [PreAlertsService],
|
||||
})
|
||||
export class PreAlertsModule {}
|
||||
|
||||
@@ -19,17 +19,29 @@ export class PreAlertsService {
|
||||
async create(dto: CreatePreAlertDto, user: any): Promise<any> {
|
||||
return this.prisma.client.preAlert.create({
|
||||
data: {
|
||||
tenantId: user.tenantId,
|
||||
userId: user.id,
|
||||
store: dto.store,
|
||||
description: dto.description,
|
||||
declaredValue: dto.declaredValue ?? 0,
|
||||
vendorTracking: dto.vendorTracking,
|
||||
status: "PENDIENTE",
|
||||
tenantId: user.tenantId,
|
||||
userId: user.id,
|
||||
store: dto.store,
|
||||
description: dto.description,
|
||||
declaredValue: dto.declaredValue ?? 0,
|
||||
vendorTracking: dto.vendorTracking,
|
||||
estimatedArrival: dto.estimatedArrival ? new Date(dto.estimatedArrival) : undefined,
|
||||
status: "PENDIENTE",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Attach invoice URL to a pre-alert (doc §07/§09) */
|
||||
async attachInvoice(id: string, invoiceKey: string, user: any): Promise<any> {
|
||||
const alert = await this.prisma.client.preAlert.findUnique({ where: { id } });
|
||||
if (!alert) throw new NotFoundException("Pre-alerta no encontrada.");
|
||||
if (user.role === "CLIENTE" && alert.userId !== user.id) throw new ForbiddenException();
|
||||
return this.prisma.client.preAlert.update({
|
||||
where: { id },
|
||||
data: { invoiceKey },
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(id: string, dto: UpdatePreAlertStatusDto): Promise<any> {
|
||||
const alert = await this.prisma.client.preAlert.findUnique({ where: { id } });
|
||||
if (!alert) throw new NotFoundException("Pre-alerta no encontrada.");
|
||||
|
||||
@@ -3,38 +3,121 @@ import { ConfigService } from "@nestjs/config";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { S3Client, DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
/**
|
||||
* StorageService — dual mode:
|
||||
* • If S3_ENDPOINT is set → MinIO / S3-compatible storage
|
||||
* • Otherwise → local disk under ./uploads/ (development)
|
||||
*/
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private readonly uploadDir: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly s3?: S3Client;
|
||||
private readonly bucket?: string;
|
||||
private readonly s3Public?: string;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.uploadDir = path.join(process.cwd(), "uploads");
|
||||
this.baseUrl = this.config.get("API_URL", "http://localhost:3001");
|
||||
// ensure uploads dir exists
|
||||
if (!fs.existsSync(this.uploadDir)) {
|
||||
fs.mkdirSync(this.uploadDir, { recursive: true });
|
||||
this.baseUrl = this.config.get("API_URL", "http://localhost:3001");
|
||||
|
||||
const endpoint = this.config.get<string>("S3_ENDPOINT");
|
||||
const accessKey = this.config.get<string>("S3_ACCESS_KEY");
|
||||
const secretKey = this.config.get<string>("S3_SECRET_KEY");
|
||||
const region = this.config.get<string>("S3_REGION", "us-east-1");
|
||||
const forcePath = this.config.get<string>("S3_FORCE_PATH_STYLE", "false") === "true";
|
||||
this.bucket = this.config.get<string>("S3_BUCKET", "moraworld");
|
||||
this.s3Public = this.config.get<string>("S3_PUBLIC_URL") ?? endpoint;
|
||||
|
||||
if (endpoint && accessKey && secretKey) {
|
||||
this.s3 = new S3Client({
|
||||
endpoint,
|
||||
region,
|
||||
forcePathStyle: forcePath,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
});
|
||||
this.logger.log(`[STORAGE] S3/MinIO mode — endpoint: ${endpoint}, bucket: ${this.bucket}`);
|
||||
} else {
|
||||
// Local disk fallback
|
||||
if (!fs.existsSync(this.uploadDir)) {
|
||||
fs.mkdirSync(this.uploadDir, { recursive: true });
|
||||
}
|
||||
this.logger.log("[STORAGE] Local disk mode (no S3_ENDPOINT configured)");
|
||||
}
|
||||
}
|
||||
|
||||
async saveFile(subdir: string, originalName: string, buffer: Buffer): Promise<string> {
|
||||
const ext = path.extname(originalName);
|
||||
get isS3Mode(): boolean { return !!this.s3; }
|
||||
|
||||
/**
|
||||
* Store a file. Returns its public URL.
|
||||
* @param subdir e.g. "packages/pkg-123" or "invoices"
|
||||
* @param originalName e.g. "photo.jpg"
|
||||
* @param buffer file contents
|
||||
* @param mimeType e.g. "image/jpeg"
|
||||
*/
|
||||
async saveFile(
|
||||
subdir: string,
|
||||
originalName: string,
|
||||
buffer: Buffer,
|
||||
mimeType = "application/octet-stream",
|
||||
): Promise<string> {
|
||||
const ext = path.extname(originalName);
|
||||
const filename = `${randomUUID()}${ext}`;
|
||||
const key = `${subdir}/${filename}`;
|
||||
|
||||
if (this.s3) {
|
||||
await this.s3.send(new PutObjectCommand({
|
||||
Bucket: this.bucket!,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: mimeType,
|
||||
// ACL not used — presigned URLs or public endpoint handle access
|
||||
}));
|
||||
// Return public path (either bucket/key or via configured public URL)
|
||||
return `${this.s3Public}/${this.bucket}/${key}`;
|
||||
}
|
||||
|
||||
// Local disk
|
||||
const dir = path.join(this.uploadDir, subdir);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const filepath = path.join(dir, filename);
|
||||
fs.writeFileSync(filepath, buffer);
|
||||
// Return URL relative to API base
|
||||
return `${this.baseUrl}/uploads/${subdir}/${filename}`;
|
||||
fs.writeFileSync(path.join(dir, filename), buffer);
|
||||
return `${this.baseUrl}/uploads/${key}`;
|
||||
}
|
||||
|
||||
async deleteFile(url: string): Promise<void> {
|
||||
/**
|
||||
* Generate a presigned GET URL for an S3 key (valid 1 hour).
|
||||
* Falls through to a direct URL if not in S3 mode.
|
||||
*/
|
||||
async presign(keyOrUrl: string, expiresIn = 3600): Promise<string> {
|
||||
if (!this.s3) return keyOrUrl;
|
||||
// If a full URL was passed, extract the key
|
||||
const key = keyOrUrl.includes(`/${this.bucket}/`)
|
||||
? keyOrUrl.split(`/${this.bucket}/`)[1]
|
||||
: keyOrUrl;
|
||||
return getSignedUrl(
|
||||
this.s3,
|
||||
new GetObjectCommand({ Bucket: this.bucket!, Key: key }),
|
||||
{ expiresIn },
|
||||
);
|
||||
}
|
||||
|
||||
/** Delete a file by its URL or S3 key */
|
||||
async deleteFile(urlOrKey: string): Promise<void> {
|
||||
try {
|
||||
const relative = url.replace(/^https?:\/\/[^/]+\/uploads\//, "");
|
||||
const filepath = path.join(this.uploadDir, relative);
|
||||
if (fs.existsSync(filepath)) fs.unlinkSync(filepath);
|
||||
if (this.s3) {
|
||||
const key = urlOrKey.includes(`/${this.bucket}/`)
|
||||
? urlOrKey.split(`/${this.bucket}/`)[1]
|
||||
: urlOrKey;
|
||||
await this.s3.send(new DeleteObjectCommand({ Bucket: this.bucket!, Key: key }));
|
||||
} else {
|
||||
const relative = urlOrKey.replace(/^https?:\/\/[^/]+\/uploads\//, "");
|
||||
const filepath = path.join(this.uploadDir, relative);
|
||||
if (fs.existsSync(filepath)) fs.unlinkSync(filepath);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
this.logger.warn(`deleteFile failed: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Controller, Get, Patch, Body, Param, Query, UseGuards } from "@nestjs/common";
|
||||
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards } from "@nestjs/common";
|
||||
import { UsersService } from "./users.service";
|
||||
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
import { IsEnum, IsBoolean } from "class-validator";
|
||||
import { IsEnum, IsBoolean, IsEmail, IsString, MinLength, IsOptional } from "class-validator";
|
||||
|
||||
class UpdateRoleDto {
|
||||
@IsEnum(["CLIENTE","OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN","SOPORTE"])
|
||||
@@ -11,6 +11,15 @@ class UpdateRoleDto {
|
||||
class SetActiveDto {
|
||||
@IsBoolean() isActive!: boolean;
|
||||
}
|
||||
class CreateUserDto {
|
||||
@IsEmail() email!: string;
|
||||
@IsString() @MinLength(8) password!: string;
|
||||
@IsString() firstName!: string;
|
||||
@IsString() lastName!: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsEnum(["CLIENTE","OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN","SOPORTE"])
|
||||
role!: string;
|
||||
}
|
||||
|
||||
@Controller("users")
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@@ -29,6 +38,13 @@ export class UsersController {
|
||||
return this.svc.findOne(id);
|
||||
}
|
||||
|
||||
/** Admin creates a user directly (doc §12) */
|
||||
@Post()
|
||||
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
create(@Body() dto: CreateUserDto, @CurrentUser() user: any): Promise<any> {
|
||||
return this.svc.create(dto, user.tenantId);
|
||||
}
|
||||
|
||||
@Patch(":id/role")
|
||||
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
updateRole(@Param("id") id: string, @Body() dto: UpdateRoleDto): Promise<any> {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { UsersController } from "./users.controller";
|
||||
import { UsersService } from "./users.service";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
|
||||
@Module({ controllers: [UsersController], providers: [UsersService] })
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Injectable, NotFoundException, ConflictException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import { generateSuiteCode } from "../common/utils/suite-code.util";
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
@@ -39,6 +41,52 @@ export class UsersService {
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Admin creates a user directly (no self-registration) */
|
||||
async create(dto: {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
}, tenantId: string): Promise<any> {
|
||||
const exists = await this.prisma.client.user.findFirst({
|
||||
where: { email: dto.email, tenantId },
|
||||
});
|
||||
if (exists) throw new ConflictException("Ya existe un usuario con ese email.");
|
||||
|
||||
const hash = await bcrypt.hash(dto.password, 12);
|
||||
|
||||
// Generate suite code for CLIENTE role
|
||||
let suiteCode: string | undefined;
|
||||
if (dto.role === "CLIENTE") {
|
||||
const count = await this.prisma.client.user.count({ where: { tenantId } });
|
||||
suiteCode = generateSuiteCode(count + 1);
|
||||
}
|
||||
|
||||
const user = await this.prisma.client.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email: dto.email,
|
||||
passwordHash: hash,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
phone: dto.phone,
|
||||
role: dto.role as any,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (suiteCode) {
|
||||
await this.prisma.client.suite.create({
|
||||
data: { tenantId, userId: user.id, code: suiteCode },
|
||||
});
|
||||
}
|
||||
|
||||
const { passwordHash: _, ...safe } = user as any;
|
||||
return safe;
|
||||
}
|
||||
|
||||
async updateRole(id: string, role: string): Promise<any> {
|
||||
return this.prisma.client.user.update({ where: { id }, data: { role: role as any } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user