init
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { Module } from '@nestjs/common';
|
||||
import dataBaseConfig from './config/mikro-orm.config';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { UserModule } from './modules/user/user.module';
|
||||
// import { CacheModule } from '@nestjs/cache-manager';
|
||||
// import { cacheConfig } from './config/cache.config';
|
||||
import { UtilsModule } from './modules/utils/utils.module';
|
||||
// import { HttpModule } from '@nestjs/axios';
|
||||
// import { httpConfig } from './config/http.config';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { UploaderModule } from './modules/uploader/uploader.module';
|
||||
import { AdminModule } from './modules/admin/admin.module';
|
||||
// import { CacheService } from './modules/utils/cache.service';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { SubmitionModule } from './modules/submition/submition.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, cache: true }),
|
||||
MikroOrmModule.forRootAsync(dataBaseConfig),
|
||||
// CacheModule.registerAsync(cacheConfig()),
|
||||
JwtModule.registerAsync({
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
global: true,
|
||||
secret: configService.getOrThrow<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.getOrThrow<number>('JWT_EXPIRATION_TIME'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
UserModule,
|
||||
UtilsModule,
|
||||
AuthModule,
|
||||
UploaderModule,
|
||||
AdminModule,
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60, // time window in seconds
|
||||
limit: 5, // max requests per window
|
||||
},
|
||||
]),
|
||||
SubmitionModule
|
||||
],
|
||||
controllers: [],
|
||||
// providers: [CacheService],
|
||||
// exports: [CacheService],
|
||||
})
|
||||
export class AppModule {}
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
// export const AUTH_THROTTLE = "AUTH_THROTTLE";
|
||||
export const AI_CONFIG = 'AI_CONFIG';
|
||||
export const MIKRO_ORM_QUERY_LOGGER = 'MIKRO_ORM_QUERY_LOGGER';
|
||||
export const NAJVA_CONFIG = 'NAJVA_CONFIG';
|
||||
export const AUTH_THROTTLE_TTL = 1 * 60 * 1000;
|
||||
export const AUTH_THROTTLE_LIMIT = 5;
|
||||
export const AUTH__REFRESH_THROTTLE_TTL = 10 * 60 * 1000;
|
||||
export const AUTH__REFRESH_THROTTLE_LIMIT = 10;
|
||||
//
|
||||
export const CONSOLE_JWT_STRATEGY_NAME = 'console_jwt_strategy';
|
||||
export const LOCAL_JWT_STRATEGY_NAME = 'local_jwt_strategy';
|
||||
|
||||
export const DANAK_IMAPS_SERVER = 'imaps.danakcorp.com';
|
||||
export const DANAK_SMTPS_SERVER = 'smtps.danakcorp.com';
|
||||
export const DANAK_POP3_SERVER = 'pop3.danakcorp.com';
|
||||
|
||||
export const DANAK_IMAPS_PORT = 993;
|
||||
export const DANAK_POP3_PORT = 995;
|
||||
export const DANAK_SMTPS_PORT = 465;
|
||||
export const DANAK_IMAPS_ENCRYPTION = 'TLS /SSL';
|
||||
export const DANAK_SMTPS_ENCRYPTION = 'TLS /SSL';
|
||||
export const DANAK_POP3_ENCRYPTION = 'TLS /SSL';
|
||||
Executable
+612
@@ -0,0 +1,612 @@
|
||||
export const enum AuthMessage {
|
||||
UNAUTHORIZED_ACCESS = "شما دسترسی لازم برای این عملیات را ندارید.",
|
||||
PHONE_REGISTERED = "شماره ثبت شد",
|
||||
INVALID_PHONE_FORMAT = "فرمت شماره تلفن صحیح نیست",
|
||||
OTP_SENT = "کد یکبار مصرف به شماره شما ارسال شد.",
|
||||
INVALID_PHONE = "شماره موبایل اشتباه می باشد",
|
||||
INVALID_CREDENTIAL = "شماره موبایل یا رمز عبور اشتباه می باشد",
|
||||
OTP_FAILED = "کد یا شماره موبایل اشتباه است",
|
||||
LOGIN_SUCCESS = "ورود با موفقیت انجام شد",
|
||||
PASSWORD_LOGIN_SUCCESS = "با موفقیت وارد شدید",
|
||||
FORGOT_PASSWORD_SUCCESS = "درخواست فراموشی رمز عبور با موفقیت ثبت شد",
|
||||
PASSWORD_SET_SUCCESS = "پسورد با موفقیت تنظیم شد",
|
||||
PASSWORD_UPDATE_SUCCESS = "رمز عبور شما تغییر کرد",
|
||||
INVALID_OTP = "کد صحیح نمیباشد یا منقضی شده است",
|
||||
PASSWORD_MISMATCH = "رمز عبور یکسان نیست",
|
||||
INVALID_PASSWORD = "ایمیل یا رمز عبور اشتباه است",
|
||||
INVALID_REPEAT_PASSWORD = "تکرار رمز عبور اشتباه است",
|
||||
EMAIL_NOT_EMPTY = "ایمیل نمیتواند خالی باشد.",
|
||||
INVALID_EMAIL_FORMAT = "فرمت ایمیل صحیح نیست",
|
||||
PasswordNotEmpty = "پسورد نمیتواند خالی باشد.",
|
||||
USER_NOT_FOUND = "کاربری با این شماره وجود ندارد",
|
||||
USER_EXISTS = "با این شماره قبلا ثبت نام شده است",
|
||||
USER_REGISTER_SUCCESS = "ثبت نام موفقیت آمیز بود",
|
||||
INVALID_USER = "کاربری با این مشخصات یافت نشد",
|
||||
PHONE_NOT_FOUND = "کاربری با این شماره یافت نشد",
|
||||
TOKEN_EXPIRED = "توکن منقضی شده است",
|
||||
TOKEN_INVALID = "توکن نامعتبر است",
|
||||
BANNED = "در حال حاضر شما امکان دسترسی به این سرویس را ندارید",
|
||||
PHONE_EXISTS = "شماره تلفن قبلا ثبت شده است",
|
||||
ADMIN_CREATED = "ادمین با موفقیت ایجاد شد",
|
||||
PERM_NOT_FOUND = "دسترسی یافت نشد",
|
||||
INVALID_PASS_FORMAT = "فرمت رمز عبور صحیح نیست",
|
||||
PHONE_NOT_EMPTY = "شماره تلفن نمیتواند خالی باشد",
|
||||
PASSWORD_FORMAT_INVALID = "رمز عبور باید به صورت رشته باشد",
|
||||
OTP_FORMAT_INVALID = "کد یکبار مصرف باید عددی و ۵ رقمی باشد",
|
||||
PHONE_FORMAT_INVALID = "شماره تلفن باید معتبر و به فرمت ایران باشد",
|
||||
TOKEN_NOT_EMPTY = "توکن نمیتواند خالی باشد",
|
||||
PASSWORD_NOT_EMPTY = "رمز عبور نمیتواند خالی باشد",
|
||||
PASSWORD_CHANGED_SUCCESSFULLY = "رمز عبور با موفقیت تغییر کرد",
|
||||
PASSWORD_CONFIRMATION_NOT_EMPTY = "تایید رمز عبور نمیتواند خالی باشد",
|
||||
PASSWORD_LENGTH = "رمز عبور باید حداقل ۸ کاراکتر باشد",
|
||||
OTP_NOT_EMPTY = "کد یکبار مصرف نمیتواند خالی باشد",
|
||||
LAST_PASSWORD_NOT_EMPTY = "رمز عبور قبلی نمیتواند خالی باشد",
|
||||
FIRST_NAME_NOT_EMPTY = "نام نمیتواند خالی باشد",
|
||||
LAST_NAME_NOT_EMPTY = "نام خانوادگی نمیتواند خالی باشد",
|
||||
FIRST_NAME_SHOULD_BE_BETWEEN_2_AND_50 = "نام باید بین ۲ تا ۵۰ کاراکتر باشد",
|
||||
LAST_NAME_SHOULD_BE_BETWEEN_2_AND_50 = "نام خانوادگی باید بین ۲ تا ۵۰ کاراکتر باشد",
|
||||
BIRTH_DATE_NOT_EMPTY = "تاریخ تولد نمیتواند خالی باشد",
|
||||
NATIONAL_CODE_INCORRECT = "کد ملی باید ۱۰ رقمی باشد",
|
||||
NATIONAL_NOT_EMPTY = "کد ملی نمیتواند خالی باشد",
|
||||
OTP_ALREADY_SENT = "کد یکبار مصرف قبلا ارسال شده است",
|
||||
TOO_MANY_REQUESTS = "تعداد درخواست های شما بیش از حد مجاز است",
|
||||
NOT_ADMIN = "شما دسترسی به بخش ادمین ندارید",
|
||||
PHONE_SHOULD_BE_11_DIGIT = "شماره تلفن باید ۱۱ رقم باشد",
|
||||
TIMESTAMP_NOT_EMPTY = "زمان نمیتواند خالی باشد",
|
||||
ADMIN_CAN_NOT_LOGIN = "ادمین نمیتواند وارد شود",
|
||||
NATIONAL_CODE_INVALID = "کد ملی نامعتبر است",
|
||||
INVALID_REFRESH_TOKEN = "توکن رفرش نامعتبر است",
|
||||
REFRESH_TOKEN_EXPIRED = "توکن رفرش منقضی شده است",
|
||||
LOGOUT_SUCCESS = "خروج با موفقیت انجام شد",
|
||||
CURRENT_PASSWORD_INVALID = "رمز عبور فعلی نامعتبر است",
|
||||
TOKEN_EXPIRED_OR_INVALID = "توکن منقضی شده یا نامعتبر است",
|
||||
ACCESS_DENIED = "دسترسی شما محدود شده است",
|
||||
USER_ACCOUNT_INACTIVE = "حساب کاربری شما غیر فعال است",
|
||||
TOKEN_MISSED_OR_EXPIRED = "توکن مفقود یا منقضی شده است",
|
||||
OLD_PASSWORD_REQUIRED = "رمز عبور قبلی الزامی است",
|
||||
PASSWORD_CHANGE_FAILED = "تغییر رمز عبور با مشکل روبرد شد",
|
||||
}
|
||||
|
||||
export const enum UserMessage {
|
||||
USER_NOT_FOUND = "کاربری با این مشخصات یافت نشد",
|
||||
USER_EXISTS = "با این شماره قبلا ثبت نام شده است",
|
||||
USER_REGISTER_SUCCESS = "ثبت نام موفقیت آمیز بود",
|
||||
EMAIL_ADDRESS_ALREADY_EXISTS = "ایمیل قبلا ثبت شده است",
|
||||
EMAIL_USER_NOT_FOUND = "ایمیل یافت نشد",
|
||||
EMAIL_USER_DELETED_SUCCESSFULLY = "ایمیل با موفقیت حذف شد",
|
||||
FAILED_TO_DELETE_EMAIL_USER = "خطا در حذف ایمیل",
|
||||
EMAIL_USER_QUOTA_UPDATED_SUCCESSFULLY = "ایمیل با موفقیت به روز رسانی شد",
|
||||
FAILED_TO_UPDATE_EMAIL_USER_QUOTA = "خطا در به روز رسانی حجم ایمیل",
|
||||
USERNAME_REQUIRED = "نام کاربری الزامی است",
|
||||
USERNAME_STRING = "نام کاربری باید یک رشته باشد",
|
||||
USERNAME_MIN_LENGTH = "نام کاربری باید حداقل ۲ کاراکتر باشد",
|
||||
USERNAME_MATCHES = "نام کاربری فقط میتواند شامل حروف، اعداد، نقطه، خط زیر و خط فاصله باشد",
|
||||
PASSWORD_MATCHES = "رمز عبور باید حداقل ۸ کاراکتر باشد و شامل حروف بزرگ، حروف کوچک، اعداد و علائم خاص باشد",
|
||||
PASSWORD_STRING = "رمز عبور باید یک رشته باشد",
|
||||
PASSWORD_REQUIRED = "رمز عبور الزامی است",
|
||||
PASSWORD_MIN_LENGTH = "رمز عبور باید حداقل ۸ کاراکتر باشد",
|
||||
DOMAIN_ID_STRING = "شناسه دامنه باید یک رشته باشد",
|
||||
DOMAIN_ID_REQUIRED = "شناسه دامنه الزامی است",
|
||||
DOMAIN_ID_UUID = "شناسه دامنه باید یک یو یو آی دی باشد",
|
||||
DISPLAY_NAME_STRING = "نام نمایشی باید یک رشته باشد",
|
||||
DISPLAY_NAME_OPTIONAL = "نام نمایشی اختیاری است",
|
||||
QUOTA_NUMBER = "حجم ایمیل باید یک عدد باشد",
|
||||
QUOTA_OPTIONAL = "حجم ایمیل اختیاری است",
|
||||
QUOTA_NOT_EMPTY = "حجم ایمیل نمیتواند خالی باشد",
|
||||
ALIASES_ARRAY = "الیاس ها باید یک آرایه باشد",
|
||||
ALIASES_STRING = "الیاس ها باید یک رشته باشد",
|
||||
ALIASES_OPTIONAL = "الیاس ها اختیاری است",
|
||||
TITLE_STRING = "عنوان باید یک رشته باشد",
|
||||
TITLE_OPTIONAL = "عنوان اختیاری است",
|
||||
TITLE_NOT_EMPTY = "عنوان نمیتواند خالی باشد",
|
||||
EMAIL_USER_CREATED_SUCCESSFULLY = "ایمیل با موفقیت ایجاد شد",
|
||||
EMAIL_USER_UPDATED_SUCCESSFULLY = "ایمیل کاربر با موفقیت بهروزرسانی شد",
|
||||
STATUS_UPDATED = "وضعیت کاربر با موفقیت تغییر کرد",
|
||||
TEMPLATE_UUID = "شناسه قالب باید یک یو یو آی دی باشد",
|
||||
TEMPLATE_NOT_EMPTY = "شناسه قالب نمیتواند خالی باشد",
|
||||
ALIASES_EMAIL = "آدرس ایمیل الیاس باید معتبر باشد",
|
||||
FORWARDERS_EMAIL_VALID = "آدرس ایمیل فوروارد باید معتبر باشد",
|
||||
FORWARDERS_ARRAY = "آدرس های فوروارد باید یک آرایه باشد",
|
||||
FORWARDERS_STRING = "آدرس های فوروارد باید یک رشته باشد",
|
||||
PROFILE_PICTURE_URL = "آدرس تصویر پروفایل باید یک آدرس اینترنتی معتبر باشد",
|
||||
USER_UPDATED_SUCCESSFULLY = "کاربر با موفقیت به روز رسانی شد",
|
||||
PROFILE_PICTURE_NOT_EMPTY = "تصویر پروفایل نمیتواند خالی باشد",
|
||||
PUSH_TOKEN_NOT_FOUND = "توکن پوش نامعتبر است",
|
||||
PUSH_TOKEN_ADDED_SUCCESSFULLY = "توکن پوش با موفقیت اضافه شد",
|
||||
NO_PUSH_TOKENS_FOUND = "توکن پوشی برای این کاربر یافت نشد",
|
||||
PUSH_TOKEN_REMOVED_SUCCESSFULLY = "توکن پوش با موفقیت حذف شد",
|
||||
}
|
||||
|
||||
export const enum CommonMessage {
|
||||
VALIDITY_TYPE_REQUIRED = "نوع اعتبار سنجی مورد نیاز است",
|
||||
THIS_FILED_IS_REQUIRED = "این فیلد الزامی است",
|
||||
VALID_FOR_CHOOSE = "معتبر برای انتخاب",
|
||||
UPDATE_SUCCESS = "با موفقیت به روز رسانی شد",
|
||||
CREATED = "با موفقیت ایجاد شد",
|
||||
DELETED = "با موفقیت حذف شد",
|
||||
ID_REQUIRED = "شناسه مورد نیاز است",
|
||||
ID_SHOULD_BE_UUID = "شناسه باید یک یو یو آی دی باشد",
|
||||
PaymentTypeQueryNotEmpty = "نوع پرداخت نمیتواند خالی باشد",
|
||||
SEARCH_QUERY_STRING = "رشته جستجو باید یک رشته باشد",
|
||||
UPDATED = "با موفقیت آپدیت شد",
|
||||
IS_ACTIVE_SHOULD_BE_1_0 = "وضعیت باید یکی از مقادیر ۰ و ۱ باشد",
|
||||
DATE_MUST_BE_DATE = "تاریخ باید یک تاریخ معتبر به صورت رشته باشد",
|
||||
}
|
||||
|
||||
export const enum UploaderMessage {
|
||||
UPLOAD_FILE_INVALID = "فایل معتبر نیست",
|
||||
UPLOAD_FILE_TOO_LARGE = "فایل بزرگتر از حد مجاز است . حداکثر حجم فایل [MAX_FILE_SIZE] مگابایت است",
|
||||
UPLOAD_FILE_TYPE_NOT_SUPPORTED = "نوع فایل معتبر نیست . مجاز است : [MIME_TYPES]",
|
||||
UPLOAD_FILE_SUCCESS = "فایل با موفقیت آپلود شد",
|
||||
}
|
||||
|
||||
export const enum EmailMessage {
|
||||
EMAIL_VERIFICATION = "تایید ایمیل",
|
||||
EMAIL_SENDING_FAILED = "ارسال ایمیل با خطا مواجه شد",
|
||||
LOGIN = "ورود به سیستم",
|
||||
INVOICE = "فاکتور",
|
||||
WALLET = "اعلان کیف پول",
|
||||
TICKET = "تیکت پشتیبانی",
|
||||
ANNOUNCEMENT = "اعلان",
|
||||
PAYMENT_REMINDER = "یادآوری پرداخت",
|
||||
PAYMENT_CANCELLATION = "لغو پرداخت",
|
||||
EMAIL_SENDING_SUCCESS = "ارسال ایمیل با موفقیت انجام شد",
|
||||
EMAIL_FORWARD_SUCCESS = "فوروارد ایمیل با موفقیت انجام شد",
|
||||
EMAIL_STATUS_RETRIEVED_SUCCESSFULLY = "وضعیت ایمیل با موفقیت دریافت شد",
|
||||
MESSAGES_SEARCHED_SUCCESSFULLY = "جستجوی ایمیل با موفقیت انجام شد",
|
||||
MESSAGES_LISTED_SUCCESSFULLY = "لیست ایمیل ها با موفقیت دریافت شد",
|
||||
MESSAGE_RETRIEVED_SUCCESSFULLY = "ایمیل با موفقیت دریافت شد",
|
||||
MESSAGE_DELETED_SUCCESSFULLY = "ایمیل با موفقیت حذف شد",
|
||||
USER_ID_REQUIRED = "شناسه کاربری مورد نیاز است",
|
||||
USER_ID_MUST_BE_STRING = "شناسه کاربری باید یک رشته باشد",
|
||||
MAILBOX_ID_REQUIRED = "شناسه میل باکس ایمیل مورد نیاز است",
|
||||
MAILBOX_ID_MUST_BE_STRING = "شناسه میل باکس ایمیل باید یک رشته باشد",
|
||||
QUEUE_ID_REQUIRED = "شناسه پیشنویس ایمیل مورد نیاز است",
|
||||
QUEUE_ID_MUST_BE_STRING = "شناسه پیشنویس ایمیل باید یک رشته باشد",
|
||||
MESSAGE_ID_MUST_BE_NUMBER = "شناسه ایمیل باید یک عدد باشد",
|
||||
FROM_ADDRESS_REQUIRED = "آدرس از مورد نیاز است",
|
||||
|
||||
// Validation Messages for Email DTOs
|
||||
FROM_ADDRESS_MUST_BE_EMAIL = "آدرس فرستنده باید یک ایمیل معتبر باشد",
|
||||
|
||||
// Query validation messages
|
||||
SEARCH_QUERY_PARAMETER_MUST_BE_STRING = "پارامتر جستجو باید یک رشته باشد",
|
||||
MESSAGE_IDS_MUST_BE_STRING = "شناسههای پیام باید یک رشته باشد",
|
||||
THREAD_ID_MUST_BE_STRING = "شناسه رشته گفتگو باید یک رشته باشد",
|
||||
OR_QUERY_MUST_BE_OBJECT = "پارامتر OR باید یک شیء باشد",
|
||||
DATE_START_MUST_BE_DATE_STRING = "تاریخ شروع باید یک رشته تاریخ معتبر باشد",
|
||||
DATE_END_MUST_BE_DATE_STRING = "تاریخ پایان باید یک رشته تاریخ معتبر باشد",
|
||||
TO_ADDRESS_MUST_BE_STRING = "آدرس گیرنده باید یک رشته باشد",
|
||||
MIN_SIZE_MUST_BE_NUMBER = "حداقل اندازه باید یک عدد باشد",
|
||||
MIN_SIZE_MUST_BE_NON_NEGATIVE = "حداقل اندازه نمیتواند منفی باشد",
|
||||
MAX_SIZE_MUST_BE_NUMBER = "حداکثر اندازه باید یک عدد باشد",
|
||||
MAX_SIZE_MUST_BE_NON_NEGATIVE = "حداکثر اندازه نمیتواند منفی باشد",
|
||||
ATTACHMENTS_FILTER_MUST_BE_BOOLEAN = "فیلتر پیوستها باید بولین باشد",
|
||||
FLAGGED_FILTER_MUST_BE_BOOLEAN = "فیلتر پیامهای علامتدار باید بولین باشد",
|
||||
UNSEEN_FILTER_MUST_BE_BOOLEAN = "فیلتر پیامهای خواندهنشده باید بولین باشد",
|
||||
INCLUDE_HEADERS_MUST_BE_STRING = "لیست هدرها باید یک رشته باشد",
|
||||
SEARCHABLE_FILTER_MUST_BE_BOOLEAN = "فیلتر قابل جستجو باید بولین باشد",
|
||||
THREAD_COUNTERS_MUST_BE_BOOLEAN = "شمارنده رشته گفتگو باید بولین باشد",
|
||||
ORDER_MUST_BE_VALID = "ترتیب باید یکی از مقادیر asc یا desc باشد",
|
||||
NEXT_CURSOR_MUST_BE_STRING = "نشانگر صفحه بعد باید یک رشته باشد",
|
||||
PREVIOUS_CURSOR_MUST_BE_STRING = "نشانگر صفحه قبل باید یک رشته باشد",
|
||||
|
||||
RECIPIENT_NAME_MUST_BE_STRING = "نام گیرنده باید یک رشته باشد",
|
||||
RECIPIENT_ADDRESS_REQUIRED = "آدرس گیرنده مورد نیاز است",
|
||||
RECIPIENT_ADDRESS_MUST_BE_EMAIL = "آدرس گیرنده باید یک ایمیل معتبر باشد",
|
||||
HEADER_KEY_REQUIRED = "کلید هدر مورد نیاز است",
|
||||
HEADER_KEY_MUST_BE_STRING = "کلید هدر باید یک رشته باشد",
|
||||
HEADER_VALUE_REQUIRED = "مقدار هدر مورد نیاز است",
|
||||
HEADER_VALUE_MUST_BE_STRING = "مقدار هدر باید یک رشته باشد",
|
||||
ATTACHMENT_FILENAME_MUST_BE_STRING = "نام فایل پیوست باید یک رشته باشد",
|
||||
ATTACHMENT_CONTENT_TYPE_MUST_BE_STRING = "نوع محتوای پیوست باید یک رشته باشد",
|
||||
ATTACHMENT_ENCODING_MUST_BE_STRING = "کدگذاری پیوست باید یک رشته باشد",
|
||||
ATTACHMENT_CONTENT_TRANSFER_ENCODING_MUST_BE_STRING = "کدگذاری انتقال محتوای پیوست باید یک رشته باشد",
|
||||
ATTACHMENT_CONTENT_DISPOSITION_INVALID = "نحوه نمایش پیوست باید inline یا attachment باشد",
|
||||
ATTACHMENT_CONTENT_REQUIRED = "محتوای پیوست مورد نیاز است",
|
||||
ATTACHMENT_CONTENT_MUST_BE_STRING = "محتوای پیوست باید یک رشته باشد",
|
||||
ATTACHMENT_CID_MUST_BE_STRING = "شناسه محتوای پیوست باید یک رشته باشد",
|
||||
DRAFT_MAILBOX_REQUIRED = "شناسه صندوق پیشنویس مورد نیاز است",
|
||||
DRAFT_MAILBOX_MUST_BE_STRING = "شناسه صندوق پیشنویس باید یک رشته باشد",
|
||||
DRAFT_ID_REQUIRED = "شناسه پیشنویس مورد نیاز است",
|
||||
DRAFT_ID_MUST_BE_NUMBER = "شناسه پیشنویس باید یک عدد باشد",
|
||||
ENVELOPE_FROM_REQUIRED = "آدرس فرستنده در envelope مورد نیاز است",
|
||||
ENVELOPE_TO_REQUIRED = "آدرس گیرنده در envelope مورد نیاز است",
|
||||
ENVELOPE_TO_MUST_BE_ARRAY = "آدرس گیرنده در envelope باید یک آرایه باشد",
|
||||
MAILBOX_MUST_BE_STRING = "شناسه صندوق باید یک رشته باشد",
|
||||
REPLY_TO_MUST_BE_VALID = "آدرس پاسخ باید معتبر باشد",
|
||||
TO_ADDRESSES_REQUIRED = "آدرسهای گیرنده مورد نیاز است",
|
||||
TO_ADDRESSES_MUST_BE_ARRAY = "آدرسهای گیرنده باید یک آرایه باشد",
|
||||
CC_ADDRESSES_MUST_BE_ARRAY = "آدرسهای کپی کربن باید یک آرایه باشد",
|
||||
BCC_ADDRESSES_MUST_BE_ARRAY = "آدرسهای کپی مخفی باید یک آرایه باشد",
|
||||
HEADERS_MUST_BE_ARRAY = "هدرهای سفارشی باید یک آرایه باشد",
|
||||
SUBJECT_MUST_BE_STRING = "موضوع ایمیل باید یک رشته باشد",
|
||||
TEXT_CONTENT_MUST_BE_STRING = "محتوای متنی باید یک رشته باشد",
|
||||
HTML_CONTENT_MUST_BE_STRING = "محتوای HTML باید یک رشته باشد",
|
||||
ATTACHMENTS_MUST_BE_ARRAY = "پیوستها باید یک آرایه باشد",
|
||||
SESSION_ID_MUST_BE_STRING = "شناسه جلسه باید یک رشته باشد",
|
||||
IP_ADDRESS_MUST_BE_STRING = "آدرس IP باید یک رشته باشد",
|
||||
IS_DRAFT_MUST_BE_BOOLEAN = "وضعیت پیشنویس باید بولین باشد",
|
||||
SEND_TIME_MUST_BE_DATE = "زمان ارسال باید یک تاریخ معتبر باشد",
|
||||
UPLOAD_ONLY_MUST_BE_BOOLEAN = "حالت فقط آپلود باید بولین باشد",
|
||||
ENVELOPE_MUST_BE_VALID = "envelope باید معتبر باشد",
|
||||
|
||||
// Message operations
|
||||
MESSAGE_MARKED_AS_SEEN_SUCCESSFULLY = "پیام با موفقیت به عنوان خوانده شده علامت گذاری شد",
|
||||
ALL_MESSAGES_MARKED_AS_READ_SUCCESSFULLY = "تمام پیام ها با موفقیت به عنوان خوانده شده علامت گذاری شدند",
|
||||
MESSAGE_MARK_AS_SEEN_FAILED = "علامت گذاری پیام با خطا مواجه شد",
|
||||
SENT_MESSAGES_RETRIEVED_SUCCESSFULLY = "پیام های ارسالی با موفقیت دریافت شد",
|
||||
SENT_MESSAGES_RETRIEVAL_FAILED = "دریافت پیام های ارسالی با خطا مواجه شد",
|
||||
TRASH_MESSAGES_RETRIEVED_SUCCESSFULLY = "پیام های حذف شده با موفقیت دریافت شد",
|
||||
TRASH_MESSAGES_RETRIEVAL_FAILED = "دریافت پیام های حذف شده با خطا مواجه شد",
|
||||
DRAFT_UPDATED_SUCCESSFULLY = "پیش نویس با موفقیت به روز رسانی شد",
|
||||
DRAFT_SENT_SUCCESSFULLY = "پیش نویس با موفقیت ارسال شد",
|
||||
DRAFT_DELETED_SUCCESSFULLY = "پیش نویس با موفقیت حذف شد",
|
||||
MESSAGE_MOVED_TO_ARCHIVE_SUCCESSFULLY = "پیام با موفقیت به آرشیو منتقل شد",
|
||||
MESSAGE_MOVED_TO_FAVORITE_SUCCESSFULLY = "پیام با موفقیت به علاقه مندی ها منتقل شد",
|
||||
MESSAGE_MOVED_TO_TRASH_SUCCESSFULLY = "پیام با موفقیت به سطل زباله منتقل شد",
|
||||
MESSAGE_MOVED_TO_JUNK_SUCCESSFULLY = "پیام با موفقیت به جانک منتقل شد",
|
||||
MESSAGE_MOVED_TO_INBOX_FROM_ARCHIVE_SUCCESSFULLY = "پیام با موفقیت از آرشیو به صندوق ورودی منتقل شد",
|
||||
MESSAGE_MOVED_TO_INBOX_FROM_FAVORITE_SUCCESSFULLY = "پیام با موفقیت از علاقه مندی ها به صندوق ورودی منتقل شد",
|
||||
MESSAGE_MOVED_TO_INBOX_FROM_TRASH_SUCCESSFULLY = "پیام با موفقیت از سطل زباله به صندوق ورودی منتقل شد",
|
||||
MESSAGE_MOVED_TO_INBOX_FROM_JUNK_SUCCESSFULLY = "پیام با موفقیت از جانک به صندوق ورودی منتقل شد",
|
||||
MESSAGE_NOT_FOUND = "پیام یافت نشد",
|
||||
|
||||
// Bulk action messages
|
||||
BULK_ACTION_COMPLETED_SUCCESSFULLY = "[count] پیام با موفقیت [action] شد",
|
||||
BULK_ACTION_PARTIALLY_COMPLETED = "[successful] از [total] پیام [action] شد. [failed] پیام با خطا مواجه شد",
|
||||
BULK_ACTION_FAILED = "هیچ پیامی [action] نشد. تمام [total] تلاش ناموفق بود",
|
||||
|
||||
// Bulk action display names
|
||||
BULK_ACTION_SEEN = "خوانده شده علامتگذاری",
|
||||
BULK_ACTION_DELETE = "حذف",
|
||||
BULK_ACTION_ARCHIVE = "آرشیو",
|
||||
BULK_ACTION_UNARCHIVE = "از آرشیو خارج",
|
||||
BULK_ACTION_FAVORITE = "به علاقه مندی ها منتقل",
|
||||
BULK_ACTION_UNFAVORITE = "از علاقه مندی ها حذف",
|
||||
BULK_ACTION_JUNK = "به جانک منتقل",
|
||||
BULK_ACTION_NOTJUNK = "از جانک از حذف",
|
||||
BULK_ACTION_TRASH = "به سطل زباله منتقل",
|
||||
BULK_ACTION_RESTORE = "از سطل زباله بازیابی",
|
||||
BULK_ACTION_PROCESSED = "پردازش",
|
||||
USER_NOT_FOUND = "کاربر یافت نشد",
|
||||
PRIORITY_MUST_BE_ONE_OF_HIGH_NORMAL_LOW = "اولویت باید یکی از مقادیر high, normal, low باشد",
|
||||
SUBJECT_REQUIRED = "موضوع ایمیل مورد نیاز است",
|
||||
ALL_MESSAGES_DELETED_SUCCESSFULLY = "همه پیام ها با موفقیت حذف شدند",
|
||||
MAILBOX_ID_MUST_BE_MONGO_ID = "شناسه میل باکس ایمیل باید یک آی دی معتبر باشد",
|
||||
MESSAGE_MARKED_AS_UNSEEN_SUCCESSFULLY = "پیام با موفقیت به عنوان خوانده نشده علامت گذاری شد",
|
||||
MESSAGE_MARK_AS_UNSEEN_FAILED = "علامت گذاری پیام به عنوان خوانده نشده با خطا مواجه شد",
|
||||
ATTACHMENT_ID_MUST_BE_STRING = "شناسه پیوست باید یک رشته باشد",
|
||||
IS_FORWARD_MUST_BE_BOOLEAN = "IS_FORWARD_MUST_BE_BOOLEAN",
|
||||
|
||||
// Favorite/Star messages (Gmail-like behavior)
|
||||
MESSAGE_MARKED_AS_FAVORITE_SUCCESSFULLY = "پیام با موفقیت به عنوان مورد علاقه علامت گذاری شد",
|
||||
MESSAGE_REMOVED_FROM_FAVORITE_SUCCESSFULLY = "پیام با موفقیت از مورد علاقه حذف شد",
|
||||
TRASH_EMPTYED_SUCCESSFULLY = "سطل زباله با موفقیت خالی شد",
|
||||
SPAM_EMPTYED_SUCCESSFULLY = "همه پیام های اسپم با موفقیت حذف شدند",
|
||||
SAVE_DRAFT_SUCCESS = "پیش نویس با موفقیت ذخیره شد",
|
||||
HTML_CONTENT_REQUIRED = "محتوای HTML مورد نیاز است",
|
||||
TEXT_CONTENT_REQUIRED = "TEXT_CONTENT_REQUIRED",
|
||||
}
|
||||
|
||||
export const enum WebSocketMessage {
|
||||
// Connection Messages
|
||||
WS_CONNECTION_ESTABLISHED = "اتصال وب سوکت با موفقیت برقرار شد",
|
||||
WS_CONNECTION_FAILED = "اتصال وب سوکت ناموفق بود",
|
||||
WS_AUTHENTICATION_REQUIRED = "احراز هویت وب سوکت مورد نیاز است",
|
||||
WS_AUTHENTICATION_FAILED = "احراز هویت وب سوکت ناموفق بود",
|
||||
WS_AUTHENTICATION_SUCCESS = "احراز هویت وب سوکت با موفقیت انجام شد",
|
||||
WS_INVALID_TOKEN = "توکن وب سوکت نامعتبر است",
|
||||
WS_TOKEN_EXPIRED = "توکن وب سوکت منقضی شده است",
|
||||
|
||||
// Room Management Messages
|
||||
JOINED_ROOM_SUCCESS = "با موفقیت به اتاق متصل شدید",
|
||||
LEFT_ROOM_SUCCESS = "با موفقیت از اتاق خارج شدید",
|
||||
ROOM_NOT_FOUND = "اتاق یافت نشد",
|
||||
UNAUTHORIZED_ROOM_ACCESS = "دسترسی غیرمجاز به اتاق",
|
||||
|
||||
// Session Management Messages
|
||||
WS_SESSION_CREATED = "جلسه وب سوکت ایجاد شد",
|
||||
WS_SESSION_JOINED = "به جلسه وب سوکت پیوستید",
|
||||
WS_SESSION_LEFT = "از جلسه وب سوکت خارج شدید",
|
||||
WS_SESSION_EXPIRED = "جلسه وب سوکت منقضی شده است",
|
||||
WS_SESSION_NOT_FOUND = "جلسه وب سوکت یافت نشد",
|
||||
|
||||
// Error Messages
|
||||
WS_CONNECTION_ERROR = "خطا در اتصال وب سوکت",
|
||||
INVALID_EVENT = "رویداد نامعتبر",
|
||||
INVALID_DATA = "داده نامعتبر",
|
||||
WS_RATE_LIMIT_EXCEEDED = "تعداد درخواستهای وب سوکت از حد مجاز بیشتر است",
|
||||
INTERNAL_ERROR = "خطای داخلی سرور",
|
||||
|
||||
// Notification Messages
|
||||
NEW_MESSAGE_NOTIFICATION = "پیام جدید دریافت شد",
|
||||
MESSAGE_STATUS_UPDATED = "وضعیت پیام بهروزرسانی شد",
|
||||
MESSAGE_MOVED_NOTIFICATION = "پیام جابجا شد",
|
||||
MESSAGE_DELETED_NOTIFICATION = "پیام حذف شد",
|
||||
|
||||
// General Messages
|
||||
OPERATION_SUCCESS = "عملیات با موفقیت انجام شد",
|
||||
OPERATION_FAILED = "عملیات ناموفق بود",
|
||||
PERMISSION_DENIED = "مجوز دسترسی ندارید",
|
||||
RESOURCE_NOT_FOUND = "منبع یافت نشد",
|
||||
|
||||
// Authentication errors
|
||||
AUTHENTICATION_REQUIRED = "احراز هویت ضروری است",
|
||||
INVALID_TOKEN = "توکن احراز هویت نامعتبر است",
|
||||
TOKEN_EXPIRED = "توکن منقضی شده است",
|
||||
UNAUTHORIZED_ACCESS = "دسترسی غیرمجاز",
|
||||
USER_NOT_AUTHENTICATED = "کاربر احراز هویت نشده است",
|
||||
|
||||
// Connection errors
|
||||
CONNECTION_FAILED = "اتصال برقرار نشد",
|
||||
WEBSOCKET_ERROR = "خطا در ارتباط WebSocket",
|
||||
CONNECTION_TIMEOUT = "زمان اتصال به پایان رسید",
|
||||
CONNECTION_REFUSED = "اتصال رد شد",
|
||||
|
||||
// Session errors
|
||||
SESSION_NOT_FOUND = "جلسه چت یافت نشد",
|
||||
SESSION_CREATION_FAILED = "ایجاد جلسه چت با شکست مواجه شد",
|
||||
SESSION_ACCESS_DENIED = "دسترسی به جلسه چت مجاز نیست",
|
||||
SESSION_EXPIRED = "جلسه چت منقضی شده است",
|
||||
SESSION_ALREADY_EXISTS = "جلسه چت قبلاً وجود دارد",
|
||||
|
||||
// Message errors
|
||||
MESSAGE_TOO_LONG = "پیام از حداکثر طول مجاز تجاوز کرده است",
|
||||
MESSAGE_EMPTY = "پیام نمیتواند خالی باشد",
|
||||
MESSAGE_SEND_FAILED = "ارسال پیام با شکست مواجه شد",
|
||||
INVALID_MESSAGE_FORMAT = "فرمت پیام نامعتبر است",
|
||||
|
||||
// Rate limiting
|
||||
RATE_LIMIT_EXCEEDED = "تعداد پیامهای ارسالی بیش از حد مجاز است",
|
||||
TOO_MANY_CONNECTIONS = "تعداد اتصالات بیش از حد مجاز است",
|
||||
|
||||
// Service errors
|
||||
LLM_SERVICE_ERROR = "سرویس هوش مصنوعی موقتاً در دسترس نیست",
|
||||
SERVICE_UNAVAILABLE = "سرویس موقتاً در دسترس نیست",
|
||||
INTERNAL_SERVER_ERROR = ".خطای داخلی سرور",
|
||||
|
||||
// Validation errors
|
||||
INVALID_SESSION_ID = "شناسه جلسه نامعتبر است",
|
||||
INVALID_USER_ID = "شناسه کاربر نامعتبر است",
|
||||
INVALID_MESSAGE_ID = "شناسه پیام نامعتبر است",
|
||||
REQUIRED_FIELD_MISSING = "فیلد ضروری موجود نیست",
|
||||
|
||||
// Success messages
|
||||
AUTHENTICATED = "احراز هویت موفقیتآمیز",
|
||||
SESSION_CREATED = "جلسه چت با موفقیت ایجاد شد",
|
||||
CHAT_JOINED = "با موفقیت به چت متصل شدید",
|
||||
MESSAGE_SENT = "پیام ارسال شد",
|
||||
CONNECTION_ESTABLISHED = "اتصال برقرار شد",
|
||||
}
|
||||
|
||||
export const enum SmsMessage {
|
||||
SEND_SMS_ERROR = "خطا در ارسال پیامک",
|
||||
}
|
||||
|
||||
export const enum BusinessMessage {
|
||||
NOT_FOUND = "کسب و کار یافت نشد",
|
||||
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 = "این دامنه قبلا تایید شده است",
|
||||
DOMAIN_REQUIRED = "دامنه مورد نیاز است",
|
||||
DOMAIN_MUST_BE_STRING = "دامنه باید یک رشته باشد",
|
||||
STAFF_NOT_FOUND = "کاربر ادمین یافت نشد",
|
||||
ZARINPAL_MERCHANT_ID_REQUIRED = "شناسه مرچنت زرین پال مورد نیاز است",
|
||||
ZARINPAL_MERCHANT_ID_STRING = "شناسه مرچنت زرین پال باید یک رشته باشد",
|
||||
LOGO_URL_REQUIRED = "آدرس تصویر لوگو مورد نیاز است",
|
||||
LOGO_URL_STRING = "آدرس تصویر لوگو باید یک رشته باشد",
|
||||
LOGO_URL_INVALID = "آدرس تصویر لوگو معتبر نیست",
|
||||
BUSINESS_SETTINGS_UPDATED = "تنظیمات کسب و کار با موفقیت به روز رسانی شد",
|
||||
NAME_REQUIRED = "نام کسب و کار مورد نیاز است",
|
||||
NAME_STRING = "نام کسب و کار باید یک رشته باشد",
|
||||
DOMAIN_CONNECTED = "دامنه با موفقیت متصل شد",
|
||||
DOMAIN_NOT_CONNECTED = "دامنه به سرور ما متصل نشد",
|
||||
DOMAIN_NOT_FOUND = "دامنه یافت نشد",
|
||||
DOMAIN_VERIFICATION_TOKEN_REQUIRED = "توکن تایید دامنه مورد نیاز است",
|
||||
INSUFFICIENT_QUOTA = "حجم شما برای ساخت ایمیل کافی نمی باشد، لطفا حجم را افزایش دهید",
|
||||
QUOTA_DOWNGRADE_NOT_ALLOWED = "کاهش حجم ایمیل مجاز نیست، فقط میتوانید حجم را افزایش دهید",
|
||||
QUOTA_AMOUNT_REQUIRED = "مقدار حجم مورد نیاز است",
|
||||
QUOTA_AMOUNT_MUST_BE_NUMBER = "مقدار حجم باید یک عدد باشد",
|
||||
QUOTA_AMOUNT_MUST_BE_POSITIVE = "مقدار حجم باید مثبت باشد",
|
||||
CALLBACK_URL_MUST_BE_STRING = "آدرس بازگشت باید یک رشته باشد",
|
||||
CALLBACK_URL_INVALID = "آدرس بازگشت معتبر نیست",
|
||||
PURCHASE_DESCRIPTION_MUST_BE_STRING = "توضیحات خرید باید یک رشته باشد",
|
||||
QUOTA_PURCHASE_SUCCESS = "درخواست خرید حجم با موفقیت ثبت شد",
|
||||
QUOTA_PURCHASE_FAILED = "خرید حجم با خطا مواجه شد",
|
||||
PAYMENT_GATEWAY_ERROR = "خطا در اتصال به درگاه پرداخت",
|
||||
QUOTA_UPDATED = "حجم ایمیل با موفقیت به روز شد",
|
||||
USER_DOES_NOT_HAVE_ACCESS_TO_SERVICE = "کاربر دسترسی به این سرویس را ندارد",
|
||||
}
|
||||
|
||||
export const enum DomainMessage {
|
||||
// Domain Ownership & Verification
|
||||
NOT_BELONG_TO_BUSINESS = "دامنه به این کسب و کار تعلق ندارد",
|
||||
NOT_VERIFIED = "دامنه باید تایید شده باشد",
|
||||
DOMAIN_NOT_FOUND = "دامنه یافت نشد",
|
||||
DOMAIN_ALREADY_EXISTS = "دامنه [name] قبلا ثبت شده است",
|
||||
DOMAIN_DELETED_SUCCESSFULLY = "دامنه با موفقیت حذف شد",
|
||||
VERIFICATION_NOT_FOUND = "تایید دامنه یافت نشد",
|
||||
NO_DOMAIN_FOUND = "دامنه ای یافت نشد",
|
||||
DOMAIN_CHECKED = "دامنه با موفقیت بررسی شد",
|
||||
|
||||
// Domain Creation & Setup
|
||||
DOMAIN_CREATED_WITH_RECORDS = "دامنه [name] با موفقیت ایجاد شد و تمام رکوردهای مورد نیاز تولید گردید",
|
||||
ALL_REQUIRED_RECORDS_GENERATED = "تمام رکوردهای مورد نیاز (MX, SPF, DMARC, DKIM) به طور خودکار برای شما تولید شدهاند",
|
||||
ESTIMATED_PROPAGATION_TIME = "تغییرات DNS معمولاً ۱۵ دقیقه تا ۴۸ ساعت در سراسر جهان منتشر میشود",
|
||||
|
||||
// DNS Records Instructions
|
||||
ADD_MX_RECORD = "یک رکورد MX با نام [name] که به [value] با اولویت [priority] اشاره کند اضافه کنید",
|
||||
ADD_TXT_RECORD = "یک رکورد TXT با نام [name] و مقدار [value] اضافه کنید",
|
||||
ADD_DKIM_RECORD = "یک رکورد TXT با نام [name] و مقدار [value] اضافه کنید (کلید عمومی DKIM)",
|
||||
ADD_DMARC_RECORD = "یک رکورد TXT با نام [name] و مقدار [value] اضافه کنید (سیاست DMARC)",
|
||||
ADD_CNAME_RECORD = "یک رکورد CNAME با نام [name] که به [value] اشاره کند اضافه کنید",
|
||||
ADD_GENERIC_RECORD = "یک رکورد [type] با نام [name] و مقدار [value] اضافه کنید",
|
||||
|
||||
// Domain Setup Steps
|
||||
STEP_LOGIN_DNS_PANEL = "وارد پنل مدیریت DNS ثبت کننده دامنه خود شوید",
|
||||
STEP_ADD_ALL_RECORDS = "تمام رکوردهای DNS مورد نیاز لیست شده در بالا را به زون DNS دامنه خود اضافه کنید",
|
||||
STEP_INCLUDE_ALL_TYPES = "مطمئن شوید که رکوردهای MX، SPF، DMARC و DKIM را برای عملکرد کامل ایمیل شامل کردهاید",
|
||||
STEP_WAIT_PROPAGATION = "منتظر انتشار DNS بمانید (معمولاً ۱۵ دقیقه تا ۴۸ ساعت)",
|
||||
STEP_USE_CHECK_ENDPOINT = "از نقطه پایانی 'بررسی DNS' برای تایید تنظیم صحیح رکوردهای خود استفاده کنید",
|
||||
STEP_DOMAIN_READY = "پس از تایید، دامنه شما برای سرویسهای ایمیل آماده خواهد بود!",
|
||||
|
||||
// Status Messages
|
||||
RECORDS_PENDING_VERIFICATION = "[count] رکورد DNS هنوز در انتظار تایید هستند",
|
||||
ALL_REQUIRED_RECORDS_MUST_BE_CONFIGURED = "تمام [count] رکورد مورد نیاز باید برای کار درست ایمیل تنظیم شوند",
|
||||
DOMAIN_FULLY_VERIFIED = "دامنه شما به طور کامل تایید شده و برای ایمیل آماده است!",
|
||||
EMAIL_ACCOUNTS_READY = "اکنون میتوانید حسابهای ایمیل ایجاد کرده و شروع به ارسال/دریافت ایمیل کنید",
|
||||
|
||||
// Verification Status
|
||||
DOMAIN_VERIFICATION_INITIATED = "فرآیند تایید دامنه آغاز شد",
|
||||
DOMAIN_VERIFICATION_FAILED = "تایید دامنه با خطا مواجه شد",
|
||||
DOMAIN_VERIFICATION_SUCCESS = "دامنه با موفقیت تایید شد",
|
||||
DOMAIN_VERIFICATION_PENDING = "تایید دامنه در انتظار است",
|
||||
|
||||
// DNS Record Status
|
||||
DNS_RECORD_VERIFIED = "رکورد DNS تایید شد",
|
||||
DNS_RECORD_FAILED = "رکورد DNS تایید نشد",
|
||||
DNS_RECORD_NOT_FOUND_OR_MISMATCH = "رکورد DNS یافت نشد یا مقدار مطابقت ندارد",
|
||||
|
||||
// DKIM Messages
|
||||
DKIM_ENABLED_AUTOMATICALLY = "DKIM به طور خودکار برای دامنه [name] فعال شد",
|
||||
DKIM_FAILED_TO_CREATE = "ایجاد DKIM برای دامنه [name] ناموفق بود",
|
||||
DKIM_ENABLED_SUCCESSFULLY = "DKIM برای دامنه [name] با موفقیت فعال شد",
|
||||
|
||||
// Error Messages
|
||||
FAILED_TO_VERIFY_DOMAIN = "تایید دامنه [name] ناموفق بود",
|
||||
FAILED_TO_SETUP_MAIL_SERVER = "راهاندازی دامنه [name] در سرور ایمیل ناموفق بود",
|
||||
DOMAIN_CREATION_FAILED = "ایجاد دامنه ناموفق بود",
|
||||
DNS_VERIFICATION_ATTEMPTS_EXCEEDED = "تعداد تلاشهای تایید DNS از حد مجاز بیشتر شد",
|
||||
|
||||
// Service Messages
|
||||
DOMAIN_UPDATED_SUCCESSFULLY = "دامنه بروزرسانی شد",
|
||||
VERIFICATION_TOKEN_PREFIX = "توکن تایید: [token]",
|
||||
RECORD_NEEDS_FIXING = "رکورد [type] برای [name] نیاز به اصلاح دارد: [error]",
|
||||
RECORD_PENDING_VERIFICATION_DETAIL = "رکورد [type] برای [name] هنوز در انتظار تایید است. لطفا مطمئن شوید که انتشار DNS کامل شده است.",
|
||||
RECORD_DESCRIPTION_EMAIL_FUNCTIONALITY = "رکورد [type] برای عملکرد ایمیل",
|
||||
BUSINESS_ALREADY_HAS_DOMAIN = "این کسب و کار قبلا دامنه ای دارد",
|
||||
CAN_NOT_USE_THIS_DOMAIN = "این دامنه نمیتواند ثبت شود",
|
||||
}
|
||||
|
||||
export const enum MailServerMessage {
|
||||
FAILED_TO_CREATE_ACCOUNT = "خطا در ایجاد ایمیل",
|
||||
FAILED_TO_CREATE_DKIM_KEY = "خطا در ایجاد کلید DKIM",
|
||||
}
|
||||
|
||||
export const enum DnsRecordMessage {
|
||||
DNS_RECORD_NOT_FOUND = "رکورد DNS یافت نشد",
|
||||
DNS_RECORD_DELETED = "رکورد DNS با موفقیت حذف شد",
|
||||
}
|
||||
|
||||
export const enum RoleMessage {
|
||||
NOT_FOUND = "نقش یافت نشد",
|
||||
}
|
||||
|
||||
export const enum SignatureMessage {
|
||||
NAME_NOT_EMPTY = "نام امضاء نمیتواند خالی باشد",
|
||||
NAME_MAX_LENGTH = "نام امضاء نمیتواند بیشتر از حداکثر طول مجاز باشد",
|
||||
NAME_STRING = "نام امضاء باید یک رشته باشد",
|
||||
TEXT_CONTENT_NOT_EMPTY = "محتوای متنی امضاء نمیتواند خالی باشد",
|
||||
TEXT_CONTENT_STRING = "محتوای متنی امضاء باید یک رشته باشد",
|
||||
TEXT_CONTENT_MAX_LENGTH = "محتوای متنی امضاء نمیتواند بیشتر از حداکثر طول مجاز باشد",
|
||||
IS_ACTIVE_NOT_EMPTY = "وضعیت فعال بودن نمیتواند خالی باشد",
|
||||
IS_ACTIVE_BOOLEAN = "وضعیت فعال بودن باید بولین باشد",
|
||||
IS_DEFAULT_NOT_EMPTY = "وضعیت پیش فرض نمیتواند خالی باشد",
|
||||
IS_DEFAULT_BOOLEAN = "وضعیت پیش فرض باید بولین باشد",
|
||||
CREATED = "امضاء با موفقیت ایجاد شد",
|
||||
NOT_FOUND = "امضاء یافت نشد",
|
||||
DELETED = "امضاء با موفقیت حذف شد",
|
||||
TOGGLE_ACTIVE = "وضعیت امضاء با موفقیت تغییر کرد",
|
||||
}
|
||||
|
||||
export const enum TemplateMessage {
|
||||
TEMPLATE_NOT_FOUND = "قالب یافت نشد",
|
||||
TEMPLATE_CREATED_SUCCESSFULLY = "قالب با موفقیت ایجاد شد",
|
||||
TEMPLATE_UPDATED_SUCCESSFULLY = "قالب با موفقیت به روزرسانی شد",
|
||||
TEMPLATE_DELETED_SUCCESSFULLY = "قالب با موفقیت حذف شد",
|
||||
TEMPLATE_RETRIEVED_SUCCESSFULLY = "قالب با موفقیت دریافت شد",
|
||||
TEMPLATES_LISTED_SUCCESSFULLY = "لیست قالب ها با موفقیت دریافت شد",
|
||||
TEMPLATE_NAME_REQUIRED = "نام قالب الزامی است",
|
||||
TEMPLATE_NAME_STRING = "نام قالب باید یک رشته باشد",
|
||||
TEMPLATE_NAME_MAX_LENGTH = "نام قالب نمیتواند بیشتر از ۲۵۵ کاراکتر باشد",
|
||||
TEMPLATE_STRUCTURE_REQUIRED = "ساختار قالب الزامی است",
|
||||
TEMPLATE_STRUCTURE_VALID = "ساختار قالب باید معتبر باشد",
|
||||
TEMPLATE_RAW_HTML_STRING = "HTML خام باید یک رشته باشد",
|
||||
TEMPLATE_BUSINESS_ID_REQUIRED = "شناسه کسب و کار الزامی است",
|
||||
TEMPLATE_BUSINESS_ID_UUID = "شناسه کسب و کار باید یک UUID معتبر باشد",
|
||||
TEMPLATE_ID_REQUIRED = "شناسه قالب الزامی است",
|
||||
TEMPLATE_ID_UUID = "شناسه قالب باید یک UUID معتبر باشد",
|
||||
TEMPLATE_ACCESS_DENIED = "شما دسترسی به این قالب را ندارید",
|
||||
TEMPLATE_BUSINESS_NOT_FOUND = "کسب و کار مالک قالب یافت نشد",
|
||||
SEARCH_STRING = "نام برای جستجو باید یک رشته باشد",
|
||||
TEMPLATE_SELECTED_SUCCESSFULLY = "وضعیت قالب با موفقیت تغییر کرد",
|
||||
TEMPLATE_THUMBNAIL_URL_VALID = "آدرس تصویر قالب باید یک آدرس معتبر باشد",
|
||||
TEMPLATE_RAW_HTML_REQUIRED = "HTML خام قالب الزامی است",
|
||||
}
|
||||
|
||||
export const enum NotificationMessage {
|
||||
TITLE_IS_REQUIRED = "عنوان اعلان الزامی است",
|
||||
TITLE_STRING = "عنوان اعلان باید یک رشته باشد",
|
||||
MESSAGE_IS_REQUIRED = "متن اعلان الزامی است",
|
||||
MESSAGE_STRING = "متن اعلان باید یک رشته باشد",
|
||||
USERID_IS_REQUIRED = "شناسه کاربر الزامی است",
|
||||
USERID_IS_STRING = "شناسه کاربر باید یک رشته باشد",
|
||||
USERID_IS_UUID = "شناسه کاربر باید یک UUID معتبر باشد",
|
||||
TYPE_IS_REQUIRED = "نوع اعلان الزامی است",
|
||||
TYPE_ENUM = "نوع اعلان باید یک enum معتبر باشد",
|
||||
NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED = "اعلان یافت نشد یا دسترسی آن را ندارید",
|
||||
LOGIN_MESSAGE = "ورود با موفقیت انجام شد در تاریخ [loginDate]",
|
||||
LOGIN = "ورود",
|
||||
NEW_EMAIL = "ایمیل جدید",
|
||||
NEW_EMAIL_MESSAGE = "یک ایمیل جدید با موضوع [subject] دریافت شد",
|
||||
CHANGE_PASSWORD_MESSAGE = "رمز عبور شما با موفقیت تغییر کرد",
|
||||
CHANGE_PASSWORD = "تغییر رمز عبور",
|
||||
}
|
||||
|
||||
export const enum SettingMessageEnum {
|
||||
SMS_MUST_BE_BOOLEAN = "تنظیمات ارسال پیامک باید یک boolean باشد",
|
||||
EMAIL_MUST_BE_BOOLEAN = "تنظیمات ارسال ایمیل باید یک boolean باشد",
|
||||
NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED = "اعلان یافت نشد یا دسترسی آن را ندارید",
|
||||
}
|
||||
|
||||
export const enum AiMessage {
|
||||
NO_RESPONSE_FROM_AI_MODEL = "خطا در دریافت پاسخ از مدل AI",
|
||||
MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = "ایمیل یافت نشد یا دسترسی آن را ندارید",
|
||||
EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = "محتوای ایمیل خالی است یا قابل استخراج نیست",
|
||||
DESCRIPTION_IS_REQUIRED = "شرح و هدف قالب الزامی است",
|
||||
DESCRIPTION_MUST_BE_A_STRING = "شرح قالب باید یک رشته باشد",
|
||||
DESCRIPTION_MUST_BE_AT_LEAST_10_CHARACTERS = "شرح قالب باید حداقل ۱۰ کاراکتر باشد",
|
||||
DESCRIPTION_MUST_BE_LESS_THAN_500_CHARACTERS = "شرح قالب نمیتواند بیشتر از ۵۰۰ کاراکتر باشد",
|
||||
THEME_MUST_BE_A_VALID_VALUE = "تم قالب باید یکی از مقادیر مجاز باشد",
|
||||
STYLE_MUST_BE_A_VALID_VALUE = "سبک قالب باید یکی از مقادیر مجاز باشد",
|
||||
PURPOSE_MUST_BE_A_VALID_VALUE = "هدف قالب باید یکی از مقادیر مجاز باشد",
|
||||
TEMPLATE_NAME_MUST_BE_A_STRING = "نام قالب باید یک رشته باشد",
|
||||
TEMPLATE_NAME_MUST_BE_LESS_THAN_100_CHARACTERS = "نام قالب نمیتواند بیشتر از ۱۰۰ کاراکتر باشد",
|
||||
PREFERRED_COLORS_MUST_BE_A_STRING = "رنگهای ترجیحی باید یک رشته باشد",
|
||||
PREFERRED_COLORS_MUST_BE_LESS_THAN_200_CHARACTERS = "رنگهای ترجیحی نمیتواند بیشتر از ۲۰۰ کاراکتر باشد",
|
||||
PREFERRED_COLORS_IS_REQUIRED = "رنگهای ترجیحی الزامی است",
|
||||
}
|
||||
|
||||
export const enum TwoFactorMessage {
|
||||
TWO_FACTOR_SETUP_INITIATED = "راهاندازی احراز هویت دو مرحلهای با موفقیت آغاز شد",
|
||||
TWO_FACTOR_ENABLED_SUCCESSFULLY = "احراز هویت دو مرحلهای با موفقیت فعال شد",
|
||||
TWO_FACTOR_DISABLED_SUCCESSFULLY = "احراز هویت دو مرحلهای با موفقیت غیرفعال شد",
|
||||
TWO_FACTOR_SETUP_FAILED = "راهاندازی احراز هویت دو مرحلهای ناموفق بود",
|
||||
TWO_FACTOR_ENABLE_FAILED = "فعالسازی احراز هویت دو مرحلهای ناموفق بود",
|
||||
TWO_FACTOR_TOKEN_VERIFIED_SUCCESSFULLY = "کد احراز هویت دو مرحلهای با موفقیت تأیید شد",
|
||||
TWO_FACTOR_TOKEN_VERIFIED_FAILED = "کد احراز هویت دو مرحلهای نامعتبر میباشد",
|
||||
TWO_FACTOR_NOT_ENABLED = "احراز هویت دو مرحلهای فعال نیست",
|
||||
TWO_FACTOR_BACKUP_CODES_GENERATED_SUCCESSFULLY = "کدهای پشتیبان با موفقیت تولید شدند",
|
||||
TWO_FACTOR_BACKUP_CODE_VERIFIED_SUCCESSFULLY = "کد پشتیبان با موفقیت تأیید شد",
|
||||
TWO_FACTOR_BACKUP_CODE_VERIFIED_FAILED = "کد پشتیبان نامعتبر است",
|
||||
TWO_FACTOR_TOKEN_REQUIRED = "کد احراز هویت دو مرحلهای الزامی است",
|
||||
TWO_FACTOR_TOKEN_INVALID = "کد احراز هویت دو مرحلهای نامعتبر است",
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// guards/jwt-auth.guard.ts
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthEntityType, AuthRequest, JwtPayload } from './auth.guard';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest<AuthRequest>();
|
||||
|
||||
try {
|
||||
const secret = this.configService.get<string>('JWT_SECRET');
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
|
||||
secret,
|
||||
});
|
||||
|
||||
if (payload.type !== AuthEntityType.ADMIN) {
|
||||
throw new UnauthorizedException('Access denied. Admin rights required.');
|
||||
}
|
||||
// 💡 We're assigning the payload to the request object here
|
||||
// so that we can access it in our route handlers
|
||||
request['user'] = payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log('error in AuthGuard', err);
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// guards/jwt-auth.guard.ts
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export enum AuthEntityType {
|
||||
USER = 'user',
|
||||
ADMIN = 'admin',
|
||||
}
|
||||
export interface AuthRequest extends Request {
|
||||
user: {
|
||||
sub: string; // userId
|
||||
};
|
||||
}
|
||||
export interface JwtPayload {
|
||||
sub: string; // The userId
|
||||
type: AuthEntityType;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest<AuthRequest>();
|
||||
|
||||
try {
|
||||
const secret = this.configService.get<string>('JWT_SECRET');
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
|
||||
secret,
|
||||
});
|
||||
if (payload.type !== AuthEntityType.USER) {
|
||||
throw new UnauthorizedException('Access denied. User rights required.');
|
||||
}
|
||||
// 💡 We're assigning the payload to the request object here
|
||||
// so that we can access it in our route handlers
|
||||
request['user'] = payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log('error in AuthGuard', err);
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
@Catch(HttpException)
|
||||
export class HttpErrorFilter implements ExceptionFilter {
|
||||
catch(exception: HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const status = exception.getStatus();
|
||||
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
let messages: (string | object)[] = [];
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
messages = [exceptionResponse];
|
||||
} else if (typeof exceptionResponse === 'object' && exceptionResponse !== null && 'message' in exceptionResponse) {
|
||||
messages = Array.isArray(exceptionResponse.message)
|
||||
? (exceptionResponse.message as string[])
|
||||
: [exceptionResponse.message as string];
|
||||
} else messages = ['Unexpected error'];
|
||||
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
messages,
|
||||
};
|
||||
|
||||
response.status(status).json(errorResponse);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { map, Observable } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor<T> implements NestInterceptor<T, any> {
|
||||
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<any> {
|
||||
return next.handle().pipe(
|
||||
map(data => ({
|
||||
success: true,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ValueProvider } from '@nestjs/common';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { MIKRO_ORM_QUERY_LOGGER } from '../constants';
|
||||
|
||||
export const mikroOrmQueryLoggerProvider: ValueProvider = {
|
||||
provide: MIKRO_ORM_QUERY_LOGGER, //
|
||||
useValue: new Logger('mikro-orm'),
|
||||
};
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import Keyv from 'keyv';
|
||||
import type { CacheModuleAsyncOptions } from '@nestjs/cache-manager';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Logger } from '@nestjs/common'; // 1. Import Logger
|
||||
|
||||
export function cacheConfig(): CacheModuleAsyncOptions {
|
||||
// 2. Create a logger instance
|
||||
const logger = new Logger('CacheConfig');
|
||||
|
||||
return {
|
||||
isGlobal: true,
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const ttl = configService.get<number>('CACHE_TTL', 120);
|
||||
const namespace = configService.get<string>('CACHE_NAMESPACE', 'app-cache');
|
||||
const redisUri = configService.getOrThrow<string>('REDIS_URI');
|
||||
|
||||
// 3. Create the KeyvRedis store first
|
||||
const keyvRedisStore = new KeyvRedis(redisUri);
|
||||
|
||||
// 4. Attach event listeners
|
||||
keyvRedisStore.client.on('connect', () => {
|
||||
logger.log('✅ Redis connected successfully.');
|
||||
});
|
||||
keyvRedisStore.client.on('error', (error) => {
|
||||
logger.error('❌ Redis connection error:', error);
|
||||
});
|
||||
|
||||
// 5. Pass the store to Keyv
|
||||
const store = new Keyv({
|
||||
store: keyvRedisStore, // Use the instance
|
||||
namespace,
|
||||
ttl: ttl * 1000,
|
||||
});
|
||||
|
||||
return {
|
||||
store,
|
||||
ttl,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
import type { HttpModuleAsyncOptions } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export function httpConfig(): HttpModuleAsyncOptions {
|
||||
return {
|
||||
inject: [ConfigService],
|
||||
global: true,
|
||||
useFactory: (configService: ConfigService) => {
|
||||
return {
|
||||
timeout: configService.getOrThrow<number>('AXIOS_TIMEOUT'),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
global: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { defineConfig, PostgreSqlDriver } from '@mikro-orm/postgresql';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
// 1. Load environment variables from your .env file
|
||||
dotenv.config();
|
||||
|
||||
// 2. Read variables directly from process.env
|
||||
const DB_PASS = process.env.DB_PASS;
|
||||
const DB_USER = process.env.DB_USER;
|
||||
const DB_HOST = process.env.DB_HOST;
|
||||
const DB_PORT = process.env.DB_PORT ? +process.env.DB_PORT : 5432;
|
||||
const DB_NAME = process.env.DB_NAME;
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
// 3. Add checks to ensure variables are loaded
|
||||
if (!DB_PASS || !DB_USER || !DB_HOST || !DB_PORT || !DB_NAME) {
|
||||
throw new Error('One or more database environment variables are not set.');
|
||||
}
|
||||
|
||||
const encodedPassword = encodeURIComponent(DB_PASS);
|
||||
|
||||
// 4. Export the result of defineConfig as the default
|
||||
export default defineConfig({
|
||||
entities: ['dist/**/*.entity.js'],
|
||||
entitiesTs: ['src/**/*.entity.ts'],
|
||||
driver: PostgreSqlDriver,
|
||||
dbName: DB_NAME,
|
||||
debug: !isProduction,
|
||||
clientUrl: `postgres://${DB_USER}:${encodedPassword}@${DB_HOST}:${DB_PORT}`,
|
||||
ensureDatabase: isProduction
|
||||
? false
|
||||
: {
|
||||
forceCheck: true,
|
||||
create: true,
|
||||
schema: 'update',
|
||||
},
|
||||
forceUtcTimezone: true,
|
||||
pool: {
|
||||
min: isProduction ? 5 : 2,
|
||||
max: isProduction ? 20 : 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
acquireTimeoutMillis: 30000,
|
||||
reapIntervalMillis: 1000,
|
||||
createTimeoutMillis: 15000,
|
||||
destroyTimeoutMillis: 5000,
|
||||
},
|
||||
// 5. Use console for CLI logging, not the injected Nest logger
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
logger: isProduction ? console.log.bind(console) : console.debug.bind(console),
|
||||
schemaGenerator: {
|
||||
disableForeignKeys: false,
|
||||
},
|
||||
migrations: {
|
||||
path: './database/migrations',
|
||||
pathTs: './database/migrations',
|
||||
tableName: 'migrations',
|
||||
transactional: true,
|
||||
allOrNothing: true,
|
||||
dropTables: false,
|
||||
safe: true,
|
||||
emit: 'ts',
|
||||
},
|
||||
connect: true,
|
||||
allowGlobalContext: true,
|
||||
driverOptions: {
|
||||
connection: {
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: 10000,
|
||||
statement_timeout: 60000,
|
||||
query_timeout: 60000,
|
||||
idle_in_transaction_session_timeout: 60000,
|
||||
connectionTimeoutMillis: 10000,
|
||||
application_name: DB_NAME,
|
||||
},
|
||||
connectionRetries: 5,
|
||||
connectionRetryDelay: 3000,
|
||||
validateConnection: (connection: any) => {
|
||||
try {
|
||||
return !!(connection && !connection.closed);
|
||||
} catch (e: unknown) {
|
||||
// Use console.error directly
|
||||
console.error(`Connection validation failed: ${(e as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
poolErrorHandler: (err: Error) => {
|
||||
// Use console.error directly
|
||||
console.error(`PostgreSQL pool error: ${err.message}`);
|
||||
if (err.message.includes('ECONNRESET') || err.message.includes('Connection terminated unexpectedly')) {
|
||||
console.warn('Connection reset detected, will attempt to reconnect');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { MikroOrmModuleAsyncOptions } from '@mikro-orm/nestjs';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PostgreSqlDriver } from '@mikro-orm/postgresql';
|
||||
// import { defineConfig } from '@mikro-orm/postgresql';
|
||||
|
||||
import type { Logger } from '@nestjs/common';
|
||||
import { MIKRO_ORM_QUERY_LOGGER } from '../common/constants';
|
||||
import { mikroOrmQueryLoggerProvider } from '../common/providers/mikro-orm-logger.provider';
|
||||
|
||||
const dataBaseConfig: MikroOrmModuleAsyncOptions = {
|
||||
// entities: ['dist/**/*.entity.js'],
|
||||
// entitiesTs: ['src/**/*.entity.ts'],
|
||||
// dbName: 'your_db_name',
|
||||
// type: 'postgresql',
|
||||
// user: 'your_user',
|
||||
// password: 'your_password',
|
||||
// host: 'localhost',
|
||||
// port: 5432,
|
||||
// debug: true,
|
||||
|
||||
inject: [ConfigService, MIKRO_ORM_QUERY_LOGGER],
|
||||
providers: [mikroOrmQueryLoggerProvider],
|
||||
useFactory: (configService: ConfigService, logger: Logger) => {
|
||||
const DB_PASS = configService.getOrThrow<string>('DB_PASS');
|
||||
const DB_USER = configService.getOrThrow<string>('DB_USER');
|
||||
const DB_HOST = configService.getOrThrow<string>('DB_HOST');
|
||||
const DB_PORT = configService.getOrThrow<number>('DB_PORT');
|
||||
const DB_NAME = configService.getOrThrow<string>('DB_NAME');
|
||||
const encodedPassword = encodeURIComponent(DB_PASS);
|
||||
const isProduction = configService.getOrThrow<string>('NODE_ENV') === 'production';
|
||||
|
||||
return ({
|
||||
// entities: ['dist/**/*.entity.js'],
|
||||
entitiesTs: ['src/**/*.entity.ts'],
|
||||
driver: PostgreSqlDriver,
|
||||
autoLoadEntities: true,
|
||||
dbName: DB_NAME,
|
||||
debug: !isProduction,
|
||||
clientUrl: `postgres://${DB_USER}:${encodedPassword}@${DB_HOST}:${DB_PORT}`,
|
||||
ensureDatabase: isProduction
|
||||
? false
|
||||
: {
|
||||
forceCheck: true,
|
||||
create: true,
|
||||
schema: 'update',
|
||||
},
|
||||
forceUtcTimezone: true,
|
||||
pool: {
|
||||
min: isProduction ? 5 : 2,
|
||||
max: isProduction ? 20 : 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
acquireTimeoutMillis: 30000,
|
||||
reapIntervalMillis: 1000,
|
||||
createTimeoutMillis: 15000,
|
||||
destroyTimeoutMillis: 5000,
|
||||
},
|
||||
logger: isProduction ? message => logger.log(message) : message => logger.debug(message),
|
||||
schemaGenerator: {
|
||||
// createForeignKey: !isProduction,
|
||||
disableForeignKeys: false,
|
||||
// createIndex: true,
|
||||
},
|
||||
migrations: {
|
||||
path: './database/migrations',
|
||||
pathTs: './database/migrations',
|
||||
tableName: 'migrations',
|
||||
transactional: true,
|
||||
allOrNothing: true,
|
||||
dropTables: false,
|
||||
safe: true,
|
||||
emit: 'ts',
|
||||
},
|
||||
connect: true,
|
||||
allowGlobalContext: true,
|
||||
driverOptions: {
|
||||
connection: {
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: 10000,
|
||||
statement_timeout: 60000,
|
||||
query_timeout: 60000,
|
||||
idle_in_transaction_session_timeout: 60000,
|
||||
connectionTimeoutMillis: 10000,
|
||||
application_name: configService.getOrThrow<string>('DB_NAME'),
|
||||
},
|
||||
connectionRetries: 5,
|
||||
connectionRetryDelay: 3000,
|
||||
validateConnection: (connection: any) => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
return !!(connection && !connection.closed);
|
||||
} catch (e: unknown) {
|
||||
logger.error(`Connection validation failed: ${(e as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
poolErrorHandler: (err: Error) => {
|
||||
logger.error(`PostgreSQL pool error: ${err.message}`);
|
||||
if (err.message.includes('ECONNRESET') || err.message.includes('Connection terminated unexpectedly')) {
|
||||
logger.warn('Connection reset detected, will attempt to reconnect');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default dataBaseConfig;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
|
||||
export function getSwaggerConfig(app: INestApplication) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Tahavol API')
|
||||
.setDescription('API documentation for Tahavol backend')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth() // optional: for JWT endpoints
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
|
||||
SwaggerModule.setup('docs', app, document, {
|
||||
swaggerOptions: {
|
||||
persistAuthorization: true,
|
||||
displayRequestDuration: true,
|
||||
filter: true,
|
||||
showExtensions: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { getSwaggerConfig } from './config/swagger.config';
|
||||
import multipart from '@fastify/multipart';
|
||||
|
||||
// 👈 Import the Fastify application type
|
||||
import { type NestFastifyApplication, FastifyAdapter } from '@nestjs/platform-fastify';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('APP');
|
||||
|
||||
// 1. Create the application using the Fastify adapter
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter(), // Instantiate the FastifyAdapter
|
||||
// Optional: Pass the custom logger (like your 'APP' logger) to NestFactory
|
||||
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] },
|
||||
);
|
||||
|
||||
await app.register(multipart);
|
||||
app.useGlobalPipes(new ValidationPipe());
|
||||
|
||||
const configService = app.get<ConfigService>(ConfigService);
|
||||
// Ensure the port is handled correctly, use 4000 as fallback if needed
|
||||
const APP_PORT = configService.getOrThrow<number>('APP_PORT') ?? 4000;
|
||||
|
||||
// 2. Swagger Integration Note
|
||||
// Since you are using platform-fastify, you must ensure your getSwaggerConfig
|
||||
// function is configured to use the Fastify platform option if necessary.
|
||||
getSwaggerConfig(app);
|
||||
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
credentials: true,
|
||||
optionsSuccessStatus: 204,
|
||||
});
|
||||
|
||||
// 3. Listen on port using Fastify's listen method
|
||||
// '0.0.0.0' is recommended for Docker/containerized environments
|
||||
await app.listen(APP_PORT, '0.0.0.0', () => {
|
||||
logger.log(`Application is running on: http://localhost:${APP_PORT}`);
|
||||
logger.log(`Swagger documentation: http://localhost:${APP_PORT}/docs`);
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminService } from './admin.service';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { Admin } from './entities/user.entity';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@Module({
|
||||
providers: [AdminService],
|
||||
controllers: [],
|
||||
imports: [MikroOrmModule.forFeature([Admin]), JwtModule],
|
||||
exports: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { EntityRepository } from '@mikro-orm/core';
|
||||
import { Admin } from './entities/user.entity';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
@InjectRepository(Admin)
|
||||
private readonly adminRepository: EntityRepository<Admin>,
|
||||
private readonly em: EntityManager,
|
||||
) {}
|
||||
|
||||
async findByPhone(phone: string): Promise<Admin | null> {
|
||||
return this.adminRepository.findOne({ phone });
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<Admin | null> {
|
||||
return this.adminRepository.findOne({ id });
|
||||
}
|
||||
|
||||
async create(phone: string): Promise<Admin> {
|
||||
const user = this.adminRepository.create({ phone });
|
||||
await this.em.persistAndFlush(user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Entity, PrimaryKey, Property, OptionalProps } from '@mikro-orm/core';
|
||||
|
||||
@Entity({ tableName: 'admns' })
|
||||
export class Admin {
|
||||
[OptionalProps]?: 'id' | 'createdAt' | 'updatedAt';
|
||||
|
||||
@PrimaryKey()
|
||||
id!: number;
|
||||
|
||||
@Property({ nullable: true })
|
||||
firstName?: string;
|
||||
|
||||
@Property({ nullable: true })
|
||||
lastName?: string;
|
||||
|
||||
@Property({ unique: true })
|
||||
phone!: string;
|
||||
|
||||
@Property({ defaultRaw: 'now()', columnType: 'timestamptz' })
|
||||
createdAt: Date = new Date();
|
||||
|
||||
@Property({ onUpdate: () => new Date(), defaultRaw: 'now()', columnType: 'timestamptz' })
|
||||
updatedAt: Date = new Date();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RequestOtpDto } from './dto/request-otp.dto';
|
||||
import { ApiTags, ApiOperation, ApiBody, ApiResponse } from '@nestjs/swagger';
|
||||
import { VerifyOtpDto } from './dto/verify-otp.dto copy';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Throttle({ default: { limit: 3, ttl: 180_000 } })
|
||||
@Post('otp/request')
|
||||
@ApiOperation({ summary: 'Request OTP for login or signup' })
|
||||
@ApiBody({ type: RequestOtpDto, description: 'Mobile number to receive OTP' })
|
||||
@ApiResponse({ status: 201, description: 'OTP requested successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid mobile number' })
|
||||
otpRequest(@Body() dto: RequestOtpDto) {
|
||||
return this.authService.requestOtp(dto);
|
||||
}
|
||||
|
||||
@Post('otp/verify')
|
||||
@ApiOperation({ summary: 'Verify OTP code' })
|
||||
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
|
||||
@ApiResponse({ status: 200, description: 'OTP verified successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid OTP or expired' })
|
||||
otpVerify(@Body() dto: VerifyOtpDto) {
|
||||
return this.authService.verifyOtp(dto.phone, dto.otp); // assuming dto has `otp` property
|
||||
}
|
||||
|
||||
@Post('admin/otp/request')
|
||||
@ApiOperation({ summary: 'Request OTP for login or signup' })
|
||||
@ApiBody({ type: RequestOtpDto, description: 'Mobile number to receive OTP' })
|
||||
@ApiResponse({ status: 201, description: 'OTP requested successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid mobile number' })
|
||||
adminOtpRequest(@Body() dto: RequestOtpDto) {
|
||||
return this.authService.requestOtpAdmin(dto);
|
||||
}
|
||||
|
||||
@Post('admin/otp/verify')
|
||||
@ApiOperation({ summary: 'Verify OTP code' })
|
||||
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
|
||||
@ApiResponse({ status: 200, description: 'OTP verified successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid OTP or expired' })
|
||||
adminOtpVerify(@Body() dto: VerifyOtpDto) {
|
||||
return this.authService.verifyOtpAdmin(dto.phone, dto.otp); // assuming dto has `otp` property
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { UtilsModule } from '../utils/utils.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AdminModule } from '../admin/admin.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UtilsModule,
|
||||
UserModule,
|
||||
JwtModule.registerAsync({
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
global: true,
|
||||
secret: configService.getOrThrow<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.getOrThrow<number>('JWT_EXPIRATION_TIME'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
AdminModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Injectable, BadRequestException, UnauthorizedException, NotFoundException } from '@nestjs/common';
|
||||
import { RequestOtpDto } from './dto/request-otp.dto';
|
||||
import { CacheService } from '../utils/cache.service';
|
||||
import { SmsService } from '../utils/sms.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { randomInt } from 'crypto';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AdminService } from '../admin/admin.service';
|
||||
import { AuthEntityType } from 'src/common/guards/auth.guard';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly smsService: SmsService,
|
||||
private readonly userService: UserService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly adminService: AdminService,
|
||||
) {}
|
||||
|
||||
async requestOtp(dto: RequestOtpDto) {
|
||||
const { phone } = dto;
|
||||
const code = this.generateOtpCode();
|
||||
|
||||
await this.cacheService.set(`otp:${phone}`, code, 160);
|
||||
|
||||
await this.smsService.sendOtp(phone, code);
|
||||
|
||||
return { success: true, message: ' OTP sent successfully' };
|
||||
}
|
||||
|
||||
async requestOtpAdmin(dto: RequestOtpDto) {
|
||||
const { phone } = dto;
|
||||
|
||||
const admin = await this.adminService.findByPhone(phone);
|
||||
if (!admin) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const code = this.generateOtpCode();
|
||||
|
||||
await this.cacheService.set(`otp-admin:${phone}`, code, 160);
|
||||
|
||||
await this.smsService.sendOtp(phone, code);
|
||||
|
||||
return { success: true, message: ' OTP sent successfully' };
|
||||
}
|
||||
|
||||
async verifyOtp(phone: string, code: string) {
|
||||
const cachedCode = await this.cacheService.get(`otp:${phone}`);
|
||||
if (!cachedCode) throw new BadRequestException('OTP expired or not found');
|
||||
if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
|
||||
|
||||
await this.cacheService.del(`otp:${phone}`);
|
||||
|
||||
const user = await this.userService.findOrCreateByPhone(phone);
|
||||
|
||||
const accessToken = await this.issueToken(user.id.toString(), AuthEntityType.USER);
|
||||
|
||||
return { success: true, message: 'User registered successfully!', accessToken };
|
||||
}
|
||||
|
||||
async verifyOtpAdmin(phone: string, code: string) {
|
||||
const cachedCode = await this.cacheService.get(`otp-admin:${phone}`);
|
||||
if (!cachedCode) throw new BadRequestException('OTP expired or not found');
|
||||
if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
|
||||
|
||||
await this.cacheService.del(`otp:${phone}`);
|
||||
|
||||
const admin = await this.adminService.findByPhone(phone);
|
||||
if (!admin) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const accessToken = await this.issueToken(admin.id.toString(), AuthEntityType.ADMIN);
|
||||
|
||||
return { success: true, message: 'successfully!', accessToken };
|
||||
}
|
||||
|
||||
private generateOtpCode(): string {
|
||||
const code = randomInt(10000, 100000);
|
||||
return code.toString();
|
||||
}
|
||||
|
||||
private issueToken(id: string, entityType: AuthEntityType) {
|
||||
const payload = {
|
||||
sub: id,
|
||||
type: entityType,
|
||||
};
|
||||
return this.jwtService.signAsync(payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsNotEmpty, IsString, Matches } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RequestOtpDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ example: '09362532122', description: 'Mobile number' })
|
||||
@Matches(/^09\d{9}$/, {
|
||||
message: 'Mobile number must be a valid Iranian phone number (e.g., 09123456789)',
|
||||
})
|
||||
// @IsMobilePhone('fa-IR')
|
||||
phone: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class VerifyOtpDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ example: '09362532122', description: 'Mobile number' })
|
||||
@Matches(/^09\d{9}$/, {
|
||||
message: 'Mobile number must be a valid Iranian phone number (e.g., 09123456789)',
|
||||
})
|
||||
// @IsMobilePhone('fa-IR')
|
||||
phone: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ example: '12345', description: 'Otp' })
|
||||
@Length(5)
|
||||
otp: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IsString, IsNotEmpty, IsDate, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateSubmissioncDto {
|
||||
@ApiPropertyOptional({ example: 'صداو سیما', description: 'دستگاه همکار : مثلا صدا و سیما' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cooperator: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ملی', description: 'ملی|استانی|' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cooperationLevel: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2025-11-03T12:30:00.000Z',
|
||||
description: 'The date and time of the submission (ISO 8601 format).',
|
||||
})
|
||||
@IsNotEmpty({ message: 'Submission Date is required.' })
|
||||
@IsDate({ message: 'Submission Date must be a valid ISO 8601 date.' })
|
||||
@Type(() => Date)
|
||||
submissionDate: Date;
|
||||
|
||||
@ApiProperty({ example: 'مقاله', description: ' ' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
submissionType: string;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { IsOptional, IsString, IsNumber, Min, IsEnum, IsIn } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Submition, SubmitionStatus } from '../entities/submition.entity';
|
||||
|
||||
const sortOrderOptions = ['asc', 'desc'] as const;
|
||||
type SortOrder = (typeof sortOrderOptions)[number];
|
||||
|
||||
export class FindSubmitionsDto {
|
||||
/**
|
||||
* The page number to retrieve.
|
||||
* @default 1
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'The page number to retrieve.',
|
||||
type: Number,
|
||||
default: 1,
|
||||
minimum: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
page?: number = 1;
|
||||
|
||||
/**
|
||||
* The number of items per page.
|
||||
* @default 10
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'The number of items per page.',
|
||||
type: Number,
|
||||
default: 10,
|
||||
minimum: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
limit?: number = 10;
|
||||
|
||||
/**
|
||||
* A general search term to filter by.
|
||||
* Searches firstName, lastName, phone, and nationalCode.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'A general search term (firstName, lastName, phone, nationalCode).',
|
||||
type: String,
|
||||
example: 'John Doe',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
/**
|
||||
* Filter by a specific user status.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by a specific user status.',
|
||||
enum: SubmitionStatus, // Use the actual enum
|
||||
example: SubmitionStatus.finished,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(SubmitionStatus)
|
||||
status?: SubmitionStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by a cooperationLevel.',
|
||||
example: 'ملی',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cooperationLevel?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by a submissionType.',
|
||||
example: 'مقاله',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
submissionType?: string;
|
||||
|
||||
/**
|
||||
* The field to sort the results by.
|
||||
* @default "user"
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'The field to sort the results by.',
|
||||
type: String,
|
||||
default: 'user',
|
||||
example: 'user',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
orderBy?: keyof Submition = 'user';
|
||||
|
||||
/**
|
||||
* The direction to sort the results.
|
||||
* @default "desc"
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'The direction to sort the results.',
|
||||
enum: sortOrderOptions, // Use the constant array
|
||||
default: 'desc',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(sortOrderOptions)
|
||||
order?: SortOrder = 'desc';
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsArray,
|
||||
ArrayMinSize,
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
@ValidatorConstraint({ name: 'MaxWordCount', async: false })
|
||||
export class MaxWordCount implements ValidatorConstraintInterface {
|
||||
validate(text: string) {
|
||||
if (typeof text !== 'string') return false;
|
||||
const wordCount = text.trim().split(/\s+/).filter(Boolean).length;
|
||||
return wordCount <= 10000;
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return 'Submission description must not exceed 1000 words.';
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateDocsDto {
|
||||
@ApiProperty({
|
||||
example: 'توضیحاتی در مورد مدارک ارسالی و هدف از ارسال.',
|
||||
description: 'Detailed description or notes regarding the documents being submitted (maximum 1000 words).',
|
||||
})
|
||||
@IsNotEmpty({ message: 'Submission description is required.' })
|
||||
@IsString({ message: 'Submission description must be a string.' })
|
||||
@Validate(MaxWordCount)
|
||||
submissionDesc: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: ['https://example.com/doc1.pdf', 'https://example.com/doc2.jpg'],
|
||||
description: 'An array of URLs pointing to the submitted documents.',
|
||||
type: [String],
|
||||
})
|
||||
@IsNotEmpty({ message: 'Document URLs are required.' })
|
||||
@IsArray({ message: 'Document URLs must be an array.' })
|
||||
@ArrayMinSize(1, { message: 'At least one document URL is required.' })
|
||||
@IsString({ each: true, message: 'Each document URL must be a valid string.' })
|
||||
docsUrl: string[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IsString, IsNotEmpty, IsDate, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class UpdateSubmissioncDto {
|
||||
@ApiPropertyOptional({ example: 'صداو سیما', description: 'دستگاه همکار : مثلا صدا و سیما' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cooperator: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ملی', description: 'ملی|استانی|' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cooperationLevel: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2025-11-03T12:30:00.000Z',
|
||||
description: 'The date and time of the submission (ISO 8601 format).',
|
||||
})
|
||||
@IsNotEmpty({ message: 'Submission Date is required.' })
|
||||
@IsDate({ message: 'Submission Date must be a valid ISO 8601 date.' })
|
||||
@Type(() => Date)
|
||||
submissionDate: Date;
|
||||
|
||||
@ApiProperty({ example: 'مقاله', description: ' ' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
submissionType: string;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Entity, Enum, PrimaryKey, Property, OptionalProps, ManyToOne } from '@mikro-orm/core';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
export enum SubmitionStatus {
|
||||
finished = 'تکمیل',
|
||||
pending = 'ناقص',
|
||||
}
|
||||
|
||||
@Entity({ tableName: 'submition' })
|
||||
export class Submition {
|
||||
[OptionalProps]?: 'id' | 'createdAt' | 'updatedAt';
|
||||
|
||||
@ManyToOne(() => User)
|
||||
user!: User;
|
||||
|
||||
@PrimaryKey()
|
||||
id!: number;
|
||||
|
||||
@Enum({ items: () => SubmitionStatus, nullable: true, default: SubmitionStatus.pending })
|
||||
status?: SubmitionStatus;
|
||||
|
||||
@Property({ nullable: true })
|
||||
submissionType?: string;
|
||||
|
||||
@Property({ nullable: true })
|
||||
cooperator?: string;
|
||||
|
||||
@Property({ nullable: true })
|
||||
cooperationLevel?: string;
|
||||
|
||||
@Property({ nullable: true, columnType: 'text' })
|
||||
submissionDesc?: string;
|
||||
|
||||
@Property({ type: 'text[]', nullable: true })
|
||||
docsUrl?: string[];
|
||||
|
||||
@Property({ columnType: 'timestamptz', nullable: true })
|
||||
submissionDate?: Date;
|
||||
|
||||
@Property({ defaultRaw: 'now()', columnType: 'timestamptz' })
|
||||
createdAt: Date = new Date();
|
||||
|
||||
@Property({ onUpdate: () => new Date(), defaultRaw: 'now()', columnType: 'timestamptz' })
|
||||
updatedAt: Date = new Date();
|
||||
|
||||
@Property({ nullable: true, columnType: 'timestamptz' })
|
||||
deletedAt?: Date;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Body,
|
||||
Req,
|
||||
UseGuards,
|
||||
Query,
|
||||
ValidationPipe,
|
||||
Post,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Delete,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody, ApiParam } from '@nestjs/swagger';
|
||||
import { AuthGuard, type AuthRequest } from 'src/common/guards/auth.guard';
|
||||
import { SubmitionService } from './submition.service';
|
||||
import { UpdateSubmissioncDto } from './dto/update-submission.dto';
|
||||
import { UpdateDocsDto } from './dto/update-docs.dto';
|
||||
import { AdminAuthGuard } from 'src/common/guards/auth.admin.guard';
|
||||
import { FindSubmitionsDto } from './dto/find-submitions.dto';
|
||||
import { CreateSubmissioncDto } from './dto/create-submission.dto';
|
||||
import { SubmitionStatus } from './entities/submition.entity';
|
||||
|
||||
@ApiTags('submition')
|
||||
@Controller('submition')
|
||||
export class SubmitionController {
|
||||
constructor(private readonly submitionService: SubmitionService) {}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get User submissions' })
|
||||
@Get('/')
|
||||
async getUserSbmitions(@Req() req: AuthRequest) {
|
||||
const userId = req.user.sub;
|
||||
const submitions = await this.submitionService.findByUserId(+userId);
|
||||
return {
|
||||
message: `User submissions `,
|
||||
submitions,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get single submission' })
|
||||
@ApiParam({ name: 'id', description: 'The ID of the submission ', type: Number })
|
||||
@Get(':id')
|
||||
async getSbmition(@Req() req: AuthRequest, @Param('id', ParseIntPipe) id: number) {
|
||||
const userId = req.user.sub;
|
||||
const submition = await this.submitionService.findById(id, +userId);
|
||||
return {
|
||||
message: `Success `,
|
||||
submition,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Create submission :first step of creation' })
|
||||
@ApiBody({ type: CreateSubmissioncDto })
|
||||
@Post('/')
|
||||
async createSubmition(@Req() req: AuthRequest, @Body() dto: CreateSubmissioncDto) {
|
||||
const userId = req.user.sub;
|
||||
const submition = await this.submitionService.create(+userId, dto);
|
||||
return {
|
||||
message: `Created submission `,
|
||||
userId,
|
||||
submition,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiParam({ name: 'id', description: 'The ID of the submission to update', type: Number })
|
||||
@ApiOperation({ summary: 'Update the submission : first step of update' })
|
||||
@ApiBody({ type: UpdateSubmissioncDto })
|
||||
@Patch(':id')
|
||||
async updateUserSubmissionType(
|
||||
@Req() req: AuthRequest,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateSubmissioncDto,
|
||||
) {
|
||||
const userId = req.user.sub;
|
||||
const submition = await this.submitionService.updateSubmition(id, +userId, dto);
|
||||
return {
|
||||
message: `PATCH request: Updating submission type for user done`,
|
||||
userId,
|
||||
submition,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiParam({ name: 'id', description: 'The ID of the submission to update', type: Number })
|
||||
@ApiOperation({ summary: 'Update the user document/file reference' })
|
||||
@ApiBody({ type: UpdateDocsDto })
|
||||
@Patch(':id/doc')
|
||||
async updateUserDoc(@Req() req: AuthRequest, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateDocsDto) {
|
||||
const userId = req.user.sub;
|
||||
const submition = await this.submitionService.updateSubmition(id, +userId, {
|
||||
...dto,
|
||||
status: SubmitionStatus.finished,
|
||||
});
|
||||
return {
|
||||
message: `PATCH request: Updating doc field for user ${userId} `,
|
||||
userId,
|
||||
submition,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'submissions' })
|
||||
@Get('/admin/submissions')
|
||||
async findAll(
|
||||
@Query(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
query: FindSubmitionsDto,
|
||||
) {
|
||||
return this.submitionService.findAll(query);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Delete submission' })
|
||||
@ApiParam({ name: 'id', description: 'The ID of the submission ', type: Number })
|
||||
@Delete(':id')
|
||||
async deleteSbmition(@Req() req: AuthRequest, @Param('id', ParseIntPipe) id: number) {
|
||||
const userId = req.user.sub;
|
||||
const submition = await this.submitionService.deleteSubmition(id, +userId);
|
||||
return {
|
||||
message: `submition deleted successfully `,
|
||||
submition,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SubmitionService } from './submition.service';
|
||||
import { SubmitionController } from './submition.controller';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { Submition } from './entities/submition.entity';
|
||||
// import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
providers: [SubmitionService],
|
||||
controllers: [SubmitionController],
|
||||
imports: [MikroOrmModule.forFeature([Submition]), JwtModule],
|
||||
exports: [SubmitionService],
|
||||
})
|
||||
export class SubmitionModule {}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { EntityRepository, FilterQuery } from '@mikro-orm/core';
|
||||
import { SubmitionStatus, Submition } from './entities/submition.entity';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { UpdateSubmissioncDto } from './dto/update-submission.dto';
|
||||
import { UpdateDocsDto } from './dto/update-docs.dto';
|
||||
import { FindSubmitionsDto } from './dto/find-submitions.dto';
|
||||
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||
import { CreateSubmissioncDto } from './dto/create-submission.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SubmitionService {
|
||||
constructor(
|
||||
@InjectRepository(Submition)
|
||||
private readonly submitionRepository: EntityRepository<Submition>,
|
||||
private readonly em: EntityManager,
|
||||
) {}
|
||||
|
||||
async updateSubmition(
|
||||
submitionId: number,
|
||||
userId: number,
|
||||
dto: UpdateSubmissioncDto | (UpdateDocsDto & { status: SubmitionStatus }),
|
||||
): Promise<Submition> {
|
||||
const sub = await this.em.findOneOrFail(Submition, submitionId, { populate: ['user'] }).catch(() => {
|
||||
throw new NotFoundException(`submition with ID ${submitionId} not found.`);
|
||||
});
|
||||
|
||||
if (sub.user.id !== userId) {
|
||||
throw new ForbiddenException(`User is not the owner of submition ID ${submitionId}.`);
|
||||
}
|
||||
|
||||
this.em.assign(sub, dto);
|
||||
|
||||
await this.em.flush();
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
async findById(submitionId: number, userId: number): Promise<Submition | null> {
|
||||
return this.submitionRepository.findOne({ id: submitionId, user: { id: userId }, deletedAt: null });
|
||||
}
|
||||
|
||||
async findByUserId(userId: number): Promise<Submition[] | null> {
|
||||
return this.submitionRepository.find({ user: userId, deletedAt: null });
|
||||
}
|
||||
|
||||
async create(userId: number, dto: CreateSubmissioncDto): Promise<Submition> {
|
||||
const submition = this.submitionRepository.create({ ...dto, user: userId });
|
||||
await this.em.persistAndFlush(submition);
|
||||
return submition;
|
||||
}
|
||||
|
||||
async findAll(dto: FindSubmitionsDto): Promise<PaginatedResult<Submition>> {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
search,
|
||||
status,
|
||||
cooperationLevel,
|
||||
submissionType,
|
||||
orderBy = 'id',
|
||||
order = 'desc',
|
||||
} = dto;
|
||||
|
||||
// 1. Calculate pagination
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// 2. Build the 'where' filter query
|
||||
const where: FilterQuery<Submition> = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
// 3. Add exact-match filters (if provided)
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
if (submissionType) {
|
||||
where.submissionType = submissionType;
|
||||
}
|
||||
|
||||
if (cooperationLevel) {
|
||||
where.cooperationLevel = cooperationLevel;
|
||||
}
|
||||
|
||||
// 4. Add 'search' logic (case-insensitive)
|
||||
if (search) {
|
||||
const searchPattern = `%${search}%`;
|
||||
where.$or = [
|
||||
{ submissionDesc: { $ilike: searchPattern } },
|
||||
{ cooperator: { $ilike: searchPattern } },
|
||||
{ user: { firstName: { $ilike: searchPattern } } },
|
||||
{ user: { lastName: { $ilike: searchPattern } } },
|
||||
{ user: { nationalCode: { $ilike: searchPattern } } },
|
||||
{ user: { personalCode: { $ilike: searchPattern } } },
|
||||
{ user: { phone: { $ilike: searchPattern } } },
|
||||
];
|
||||
}
|
||||
|
||||
// 5. Execute the query using findAndCount
|
||||
const [sumitions, total] = await this.submitionRepository.findAndCount(where, {
|
||||
limit,
|
||||
offset,
|
||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||
populate: ['user'],
|
||||
});
|
||||
|
||||
// 6. Calculate total pages
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
// 7. Return the paginated result
|
||||
return {
|
||||
data: sumitions,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async deleteSubmition(submitionId: number, userId: number): Promise<Submition> {
|
||||
const submission = await this.em.findOneOrFail(Submition, submitionId, { populate: ['user'] }).catch(() => {
|
||||
throw new NotFoundException(`Submition with ID ${submitionId} not found.`);
|
||||
});
|
||||
|
||||
// Ownership Check (same as before)
|
||||
if (submission.user.id !== userId) {
|
||||
throw new ForbiddenException(`User with ID ${userId} is not authorized to delete submition ID ${submitionId}.`);
|
||||
}
|
||||
|
||||
// Execute the Soft Delete: Update the timestamp
|
||||
submission.deletedAt = new Date();
|
||||
await this.em.flush();
|
||||
|
||||
return submission;
|
||||
}
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
import type { File } from '@nest-lab/fastify-multer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UploadSingleFileDto {
|
||||
@ApiProperty({ type: 'string', format: 'binary', nullable: false })
|
||||
file: File;
|
||||
}
|
||||
|
||||
export class UploadMultipleFileDto {
|
||||
@ApiProperty({
|
||||
required: true,
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
},
|
||||
})
|
||||
files: File[];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { File } from "@nest-lab/fastify-multer";
|
||||
|
||||
export interface FileUploadResult {
|
||||
file: File;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface FileStreamResult {
|
||||
stream: NodeJS.ReadableStream;
|
||||
file: File;
|
||||
}
|
||||
|
||||
export interface FileMetadata {
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
bitrate?: number;
|
||||
codec?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export type FileType = "video" | "audio" | "image" | "document" | "text" | "unknown";
|
||||
|
||||
export interface FileValidationOptions {
|
||||
maxFileSize: number;
|
||||
allowedMimeTypes: string[];
|
||||
}
|
||||
|
||||
export interface FileUploadOptions {
|
||||
roomId?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// File processing status
|
||||
export type FileProcessingStatus = "pending" | "processing" | "completed" | "failed";
|
||||
|
||||
export interface FileProcessingResult {
|
||||
status: FileProcessingStatus;
|
||||
metadata?: FileMetadata;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface S3UploadResult {
|
||||
key: string;
|
||||
url: string;
|
||||
bucket: string;
|
||||
}
|
||||
|
||||
export interface S3UploadOptions {
|
||||
contentType: string;
|
||||
metadata?: Record<string, string>;
|
||||
acl?: "private" | "public-read" | "public-read-write";
|
||||
}
|
||||
|
||||
export interface S3DownloadOptions {
|
||||
expiresIn?: number;
|
||||
responseContentType?: string;
|
||||
responseContentDisposition?: string;
|
||||
}
|
||||
|
||||
export interface S3FileInfo {
|
||||
key: string;
|
||||
bucket: string;
|
||||
size: number;
|
||||
lastModified: Date;
|
||||
contentType: string;
|
||||
etag: string;
|
||||
}
|
||||
|
||||
export interface S3DeleteResult {
|
||||
deleted: boolean;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface S3ListResult {
|
||||
files: S3FileInfo[];
|
||||
continuationToken?: string;
|
||||
isTruncated: boolean;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { FileType } from '../interfaces/file.interface';
|
||||
import { S3UploadResult } from '../interfaces/s3.interface';
|
||||
|
||||
@Injectable()
|
||||
export class S3Service {
|
||||
private readonly logger = new Logger(S3Service.name);
|
||||
private readonly s3Client: S3Client;
|
||||
private readonly bucketName: string;
|
||||
private readonly region: string; // Region is often 'us-east-1' or similar for Liara, but depends on your config
|
||||
private readonly endpointUrl: string; // Renamed 'url' to 'endpointUrl' for clarity
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
// --- Configuration for Liara S3 ---
|
||||
this.bucketName = this.configService.getOrThrow<string>('BUCKET_NAME');
|
||||
// Liara S3 usually requires a specific endpoint URL
|
||||
this.endpointUrl = this.configService.getOrThrow<string>('LIARA_S3_ENDPOINT');
|
||||
// The region for Liara S3 is often 'us-east-1' or just a placeholder,
|
||||
// but we'll keep it configurable for consistency.
|
||||
this.region = this.configService.getOrThrow<string>('BUCKET_REGION');
|
||||
|
||||
this.s3Client = new S3Client({
|
||||
region: this.region, // Use the configured region
|
||||
endpoint: this.endpointUrl, // This is the crucial change for Liara S3
|
||||
forcePathStyle: true, // Often required when using custom S3 endpoints like Liara
|
||||
credentials: {
|
||||
accessKeyId: this.configService.getOrThrow<string>('BUCKET_ACCESS_KEY'),
|
||||
secretAccessKey: this.configService.getOrThrow<string>('BUCKET_SECRET_KEY'),
|
||||
},
|
||||
});
|
||||
// ------------------------------------
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file to S3
|
||||
*/
|
||||
async uploadFile(
|
||||
buffer: Buffer,
|
||||
key: string,
|
||||
contentType: string,
|
||||
metadata?: Record<string, string>,
|
||||
): Promise<S3UploadResult> {
|
||||
try {
|
||||
const sanitizedMetadata = metadata
|
||||
? Object.entries(metadata).reduce(
|
||||
(acc, [key, value]) => {
|
||||
// Encode all values to be ASCII-safe
|
||||
acc[key] = encodeURIComponent(value);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined;
|
||||
// NOTE: 'ACL: public-read' might not be supported or necessary for Liara S3,
|
||||
// depending on your bucket configuration on Liara.
|
||||
// If uploads fail, try removing this line.
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
// ACL: 'public-read',
|
||||
ContentType: contentType,
|
||||
Metadata: sanitizedMetadata,
|
||||
});
|
||||
|
||||
await this.s3Client.send(command);
|
||||
|
||||
// --- URL for Liara S3 ---
|
||||
// The public URL for Liara S3 files is typically: ${endpoint}/${bucketName}/${key}
|
||||
const url = `${this.endpointUrl}/${this.bucketName}/${key}`;
|
||||
|
||||
this.logger.log(`File uploaded to S3: ${key}`);
|
||||
|
||||
return {
|
||||
key,
|
||||
url,
|
||||
bucket: this.bucketName,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Failed to upload file to S3: ${(error as Error).message}`, (error as Error).stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Other methods remain the same as they use the S3Client which is now configured for Liara ---
|
||||
|
||||
/**
|
||||
* Get file stream from S3
|
||||
*/
|
||||
|
||||
async getFileStream(key: string): Promise<Readable> {
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const response = await this.s3Client.send(command);
|
||||
|
||||
// ✅ VALIDATION
|
||||
if (response.Body instanceof Readable) {
|
||||
return response.Body;
|
||||
}
|
||||
|
||||
// If it's not a Readable stream, throw a specific error
|
||||
throw new Error(`File body is not a readable stream for key: ${key}`);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Failed to get file from S3: ${(error as Error).message}`, (error as Error).stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Generate presigned URL for file download
|
||||
*/
|
||||
async getPresignedUrl(key: string, expiresIn: number = 3600): Promise<string> {
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
return await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Failed to generate presigned URL: ${(error as Error).message}`, (error as Error).stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete file from S3
|
||||
*/
|
||||
async deleteFile(key: string): Promise<void> {
|
||||
try {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
await this.s3Client.send(command);
|
||||
this.logger.log(`File deleted from S3: ${key}`);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Failed to delete file from S3: ${(error as Error).message}`, (error as Error).stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate S3 key for file
|
||||
*/
|
||||
generateFileKey(originalName: string, fileType: FileType): string {
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = randomInt(1000000, 9999999);
|
||||
const extension = originalName.split('.').pop();
|
||||
|
||||
const basePathMap: Record<FileType, string> = {
|
||||
image: 'images',
|
||||
video: 'videos',
|
||||
audio: 'audio',
|
||||
document: 'documents',
|
||||
text: 'texts',
|
||||
unknown: 'unknowns',
|
||||
};
|
||||
|
||||
const basePath = basePathMap[fileType];
|
||||
return `${basePath}/${timestamp}-${randomSuffix}.${extension}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { File } from '@nest-lab/fastify-multer';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { S3Service } from './s3.service';
|
||||
import { UploaderMessage } from '../../../common/enums/message.enum';
|
||||
import { FileType } from '../interfaces/file.interface';
|
||||
|
||||
@Injectable()
|
||||
export class UploaderService {
|
||||
private readonly logger = new Logger(UploaderService.name);
|
||||
private readonly maxFileSize: number;
|
||||
private readonly allowedMimeTypes: string[];
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly s3Service: S3Service,
|
||||
) {
|
||||
this.maxFileSize = this.configService.get('MAX_FILE_SIZE', 1024 * 1024 * 100); // 100MB
|
||||
this.allowedMimeTypes = [
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/ogg',
|
||||
'video/avi',
|
||||
'video/mov',
|
||||
'video/mkv',
|
||||
'audio/mp3',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
'audio/m4a',
|
||||
'audio/aac',
|
||||
'audio/flac',
|
||||
'audio/m4a',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
'image/tiff',
|
||||
'image/bmp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.ms-excel.sheet.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
|
||||
'application/vnd.ms-excel.template.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.template.macroEnabled.12',
|
||||
'application/vnd.ms-excel.addin.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.addin.macroEnabled.12',
|
||||
'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.presentation.binary.macroEnabled.12',
|
||||
'application/vnd.ms-excel.template.binary.macroEnabled.12',
|
||||
'application/vnd.ms-powerpoint.template.binary.macroEnabled.12',
|
||||
'application/vnd.ms-excel.addin.binary.macroEnabled.12',
|
||||
];
|
||||
}
|
||||
|
||||
async uploadMultiple(files: File[]) {
|
||||
return await Promise.all(files.map(file => this.uploadFile(file)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file upload
|
||||
*/
|
||||
async uploadFile(file: File) {
|
||||
if (!file || !file.buffer || !file.size) {
|
||||
throw new BadRequestException(UploaderMessage.UPLOAD_FILE_INVALID);
|
||||
}
|
||||
this.logger.log(`Uploading file: ${file.originalname}`);
|
||||
|
||||
this.validateFile(file);
|
||||
|
||||
const fileType = await this.determineFileType(file.mimetype);
|
||||
|
||||
const s3Key = this.s3Service.generateFileKey(file.originalname, fileType);
|
||||
|
||||
// Upload to S3
|
||||
const s3Result = await this.s3Service.uploadFile(file.buffer, s3Key, file.mimetype, {
|
||||
originalName: file.originalname,
|
||||
uploadedBy: 'admin',
|
||||
});
|
||||
|
||||
this.logger.log(`File uploaded successfully: `);
|
||||
|
||||
return {
|
||||
file: s3Result,
|
||||
url: s3Result.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate uploaded file
|
||||
*/
|
||||
private validateFile(file: File): void {
|
||||
if (!file) throw new BadRequestException(UploaderMessage.UPLOAD_FILE_INVALID);
|
||||
|
||||
if (file.size && file.size > this.maxFileSize)
|
||||
throw new BadRequestException(
|
||||
UploaderMessage.UPLOAD_FILE_TOO_LARGE.replace('[MAX_FILE_SIZE]', this.maxFileSize.toString()),
|
||||
);
|
||||
|
||||
if (!this.allowedMimeTypes.includes(file.mimetype))
|
||||
throw new BadRequestException(
|
||||
UploaderMessage.UPLOAD_FILE_TYPE_NOT_SUPPORTED.replace('[MIME_TYPES]', file.mimetype),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine file type based on mimetype
|
||||
*/
|
||||
private async determineFileType(mimetype: string): Promise<FileType> {
|
||||
if (mimetype.startsWith('video/')) {
|
||||
return 'video';
|
||||
} else if (mimetype.startsWith('audio/')) {
|
||||
return 'audio';
|
||||
} else if (mimetype.startsWith('image/')) {
|
||||
return 'image';
|
||||
} else if (mimetype.startsWith('text/')) {
|
||||
return 'text';
|
||||
} else if (mimetype.startsWith('application/')) {
|
||||
return 'document';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type File, FileInterceptor, FilesInterceptor } from '@nest-lab/fastify-multer';
|
||||
import { Controller, Post, UploadedFile, UploadedFiles, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { ApiBody, ApiConsumes, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
|
||||
import { UploadMultipleFileDto, UploadSingleFileDto } from './DTO/upload-file.dto';
|
||||
import { UploaderService } from './providers/uploader.service';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
|
||||
@Controller('uploader')
|
||||
export class UploaderController {
|
||||
constructor(private readonly uploaderService: UploaderService) {}
|
||||
|
||||
@ApiOperation({ summary: 'Upload a file (admin)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBearerAuth()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiBody({ type: UploadSingleFileDto })
|
||||
@Post('single-file')
|
||||
@UseGuards(AuthGuard)
|
||||
uploadFile(@UploadedFile() file: File) {
|
||||
return this.uploaderService.uploadFile(file);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Uploads multiple files' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FilesInterceptor('files'))
|
||||
@ApiBearerAuth()
|
||||
@ApiBody({ type: UploadMultipleFileDto })
|
||||
@Post('multi-file')
|
||||
@UseGuards(AuthGuard)
|
||||
@UseGuards(AuthGuard)
|
||||
uploadFiles(@UploadedFiles() files: File[]) {
|
||||
return this.uploaderService.uploadMultiple(files);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { S3Service } from './providers/s3.service';
|
||||
import { UploaderService } from './providers/uploader.service';
|
||||
import { UploaderController } from './uploader.controller';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@Module({
|
||||
controllers: [UploaderController],
|
||||
providers: [UploaderService, S3Service],
|
||||
exports: [UploaderService, S3Service],
|
||||
imports: [JwtModule],
|
||||
})
|
||||
export class UploaderModule {}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { IsOptional, IsString, IsNumber, Min, IsEnum, IsIn } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { EducationLevel, User } from '../entities/user.entity';
|
||||
|
||||
// Define the valid sort directions
|
||||
const sortOrderOptions = ['asc', 'desc'] as const;
|
||||
type SortOrder = (typeof sortOrderOptions)[number];
|
||||
|
||||
export class FindUsersDto {
|
||||
/**
|
||||
* The page number to retrieve.
|
||||
* @default 1
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'The page number to retrieve.',
|
||||
type: Number,
|
||||
default: 1,
|
||||
minimum: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
page?: number = 1;
|
||||
|
||||
/**
|
||||
* The number of items per page.
|
||||
* @default 10
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'The number of items per page.',
|
||||
type: Number,
|
||||
default: 10,
|
||||
minimum: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
limit?: number = 10;
|
||||
|
||||
/**
|
||||
* A general search term to filter by.
|
||||
* Searches firstName, lastName, phone, and nationalCode.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'A general search term (firstName, lastName, phone, nationalCode).',
|
||||
type: String,
|
||||
example: 'John Doe',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
/**
|
||||
* Filter by a specific education level.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by a specific education level.',
|
||||
enum: EducationLevel, // Use the actual enum
|
||||
example: EducationLevel.bachelor,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(EducationLevel)
|
||||
education?: EducationLevel;
|
||||
|
||||
/**
|
||||
* The field to sort the results by.
|
||||
* @default "createdAt"
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'The field to sort the results by.',
|
||||
type: String,
|
||||
default: 'createdAt',
|
||||
// You can provide a list of valid keys for better documentation
|
||||
// You must ensure 'keyof User' is correctly exported/available here
|
||||
example: 'lastName',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
orderBy?: keyof User = 'createdAt';
|
||||
|
||||
/**
|
||||
* The direction to sort the results.
|
||||
* @default "desc"
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'The direction to sort the results.',
|
||||
enum: sortOrderOptions, // Use the constant array
|
||||
default: 'desc',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(sortOrderOptions)
|
||||
order?: SortOrder = 'desc';
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { IsString, IsNotEmpty, IsBoolean, IsNumber, IsEnum, MinLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { EducationLevel } from '../entities/user.entity';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiProperty({ example: ' ', description: "User's first name" })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ example: ' ', description: "User's last name" })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ example: ' ', description: "User's father's name" })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
fatherName: string;
|
||||
|
||||
// --- Personal Details ---
|
||||
|
||||
@ApiPropertyOptional({ example: true, description: 'Gender: true for male, false for female' })
|
||||
@IsNotEmpty()
|
||||
@IsBoolean()
|
||||
isMale: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: '1234567890', description: 'Unique national code' })
|
||||
@IsNotEmpty()
|
||||
@MinLength(10)
|
||||
nationalCode: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '1001', description: 'Employee personal code' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
personalCode: string;
|
||||
|
||||
// --- Employment Details ---
|
||||
|
||||
@ApiPropertyOptional({ example: 'قراردادی', description: 'Type of hire (e.g., permanent, temporary)' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
hireType: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'اراک', description: 'Primary work location' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
workLocation: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 5, description: 'Years of work experience' })
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
workExperienceYear: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: EducationLevel,
|
||||
example: EducationLevel.master,
|
||||
description: 'Highest level of education achieved',
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsEnum(EducationLevel)
|
||||
education: EducationLevel;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Entity, Enum, PrimaryKey, Property, OptionalProps, OneToMany, Collection } from '@mikro-orm/core';
|
||||
import { Submition } from '../../submition/entities/submition.entity';
|
||||
|
||||
export enum EducationLevel {
|
||||
diploma = 'دیپلم',
|
||||
associate = 'کاردانی',
|
||||
bachelor = 'کارشناسی',
|
||||
master = 'کارشناسی ارشد',
|
||||
phd = 'دکتری',
|
||||
}
|
||||
|
||||
export enum UserStatus {
|
||||
finished = 'finished',
|
||||
pending = 'pending',
|
||||
}
|
||||
|
||||
@Entity({ tableName: 'users' })
|
||||
export class User {
|
||||
[OptionalProps]?: 'id' | 'createdAt' | 'updatedAt';
|
||||
|
||||
@OneToMany(() => Submition, submition => submition.user)
|
||||
submissions = new Collection<Submition>(this);
|
||||
|
||||
@PrimaryKey()
|
||||
id!: number;
|
||||
|
||||
@Property({ nullable: true })
|
||||
firstName?: string;
|
||||
|
||||
@Property({ nullable: true })
|
||||
lastName?: string;
|
||||
|
||||
@Property({ nullable: true })
|
||||
fatherName?: string;
|
||||
|
||||
@Property({ unique: true })
|
||||
phone!: string;
|
||||
|
||||
@Property({ nullable: true })
|
||||
isMale?: boolean;
|
||||
|
||||
@Property({ unique: true, nullable: true })
|
||||
nationalCode?: string;
|
||||
|
||||
@Property({ nullable: true })
|
||||
personalCode?: string;
|
||||
|
||||
@Property({ nullable: true })
|
||||
hireType?: string;
|
||||
|
||||
@Property({ nullable: true })
|
||||
workLocation?: string;
|
||||
|
||||
@Property({ default: 0, nullable: true })
|
||||
workExperienceYear?: number;
|
||||
|
||||
@Enum({ items: () => EducationLevel, nullable: true })
|
||||
education?: EducationLevel;
|
||||
|
||||
@Enum({ items: () => UserStatus, nullable: true, default: UserStatus.pending })
|
||||
status?: UserStatus;
|
||||
|
||||
@Property({ defaultRaw: 'now()', columnType: 'timestamptz' })
|
||||
createdAt: Date = new Date();
|
||||
|
||||
@Property({ onUpdate: () => new Date(), defaultRaw: 'now()', columnType: 'timestamptz' })
|
||||
updatedAt: Date = new Date();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Controller, Get, Patch, Body, Req, UseGuards, Query, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody } from '@nestjs/swagger';
|
||||
import { AuthGuard, type AuthRequest } from 'src/common/guards/auth.guard';
|
||||
import { UserService } from './user.service';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { FindUsersDto } from './dto/find-user.dto';
|
||||
import { AdminAuthGuard } from 'src/common/guards/auth.admin.guard';
|
||||
|
||||
@ApiTags('User')
|
||||
@Controller('user')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get the current authenticated user profile' })
|
||||
@Get('/')
|
||||
async getUser(@Req() req: AuthRequest) {
|
||||
const userId = req.user.sub;
|
||||
const user = await this.userService.findById(+userId);
|
||||
return {
|
||||
message: `GET request: Retrieving profile for authenticated user `,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Update User Specification (PATCH /user/spec)
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Update the user specification' })
|
||||
@ApiBody({ type: UpdateUserDto })
|
||||
@Patch('/')
|
||||
async updateUserSpec(@Req() req: AuthRequest, @Body() dto: UpdateUserDto) {
|
||||
const userId = req.user.sub;
|
||||
const user = await this.userService.updateUser(+userId, dto);
|
||||
return {
|
||||
message: `PATCH request: Updating specification for user ${userId} `,
|
||||
userId,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Users : Admin Route' })
|
||||
@Get('/admin/users')
|
||||
async findAll(
|
||||
// Use ValidationPipe to validate and transform the DTO
|
||||
@Query(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
query: FindUsersDto,
|
||||
) {
|
||||
return this.userService.findAll(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
import { UserController } from './user.controller';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { User } from './entities/user.entity';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@Module({
|
||||
providers: [UserService],
|
||||
controllers: [UserController],
|
||||
imports: [MikroOrmModule.forFeature([User]), JwtModule],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { EntityRepository, FilterQuery } from '@mikro-orm/core';
|
||||
import { User, UserStatus } from './entities/user.entity';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { FindUsersDto } from './dto/find-user.dto';
|
||||
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: EntityRepository<User>,
|
||||
private readonly em: EntityManager,
|
||||
) {}
|
||||
|
||||
async findOrCreateByPhone(phone: string): Promise<User> {
|
||||
let user = await this.userRepository.findOne({ phone });
|
||||
|
||||
if (!user) {
|
||||
user = this.userRepository.create({
|
||||
phone,
|
||||
});
|
||||
await this.em.persistAndFlush(user);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async updateUser(userId: number, dto: UpdateUserDto): Promise<User> {
|
||||
const user = await this.em.findOneOrFail(User, userId, {}).catch(() => {
|
||||
throw new NotFoundException(`User with ID ${userId} not found.`);
|
||||
});
|
||||
|
||||
this.em.assign(user, { ...dto, status: UserStatus.finished });
|
||||
|
||||
await this.em.flush();
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async findByPhone(phone: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({ phone });
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<User | null> {
|
||||
return this.userRepository.findOne({ id });
|
||||
}
|
||||
|
||||
async create(phone: string): Promise<User> {
|
||||
const user = this.userRepository.create({ phone });
|
||||
await this.em.persistAndFlush(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async findAll(dto: FindUsersDto): Promise<PaginatedResult<User>> {
|
||||
const { page = 1, limit = 10, search, education, orderBy = 'createdAt', order = 'desc' } = dto;
|
||||
|
||||
// 1. Calculate pagination
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// 2. Build the 'where' filter query
|
||||
const where: FilterQuery<User> = {};
|
||||
|
||||
if (education) {
|
||||
where.education = education;
|
||||
}
|
||||
|
||||
// 4. Add 'search' logic (case-insensitive)
|
||||
if (search) {
|
||||
const searchPattern = `%${search}%`;
|
||||
// $ilike is case-insensitive (PostgreSQL). Use $like for MySQL (often CI by default)
|
||||
where.$or = [
|
||||
{ firstName: { $ilike: searchPattern } },
|
||||
{ lastName: { $ilike: searchPattern } },
|
||||
{ phone: { $ilike: searchPattern } },
|
||||
{ nationalCode: { $ilike: searchPattern } },
|
||||
{ personalCode: { $ilike: searchPattern } },
|
||||
];
|
||||
}
|
||||
|
||||
// 5. Execute the query using findAndCount
|
||||
const [users, total] = await this.userRepository.findAndCount(where, {
|
||||
limit,
|
||||
offset,
|
||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||
});
|
||||
|
||||
// 6. Calculate total pages
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
// 7. Return the paginated result
|
||||
return {
|
||||
data: users,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
|
||||
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class CacheService implements OnModuleInit {
|
||||
private readonly logger = new Logger(CacheService.name);
|
||||
async onModuleInit() {
|
||||
this.logger.log('Pinging Redis to check connection...');
|
||||
try {
|
||||
// 3. Try to set and get a test key
|
||||
await this.cacheManager.set('ping', 'pong', 10); // 10-second TTL
|
||||
const result = await this.cacheManager.get('ping');
|
||||
|
||||
if (result === 'pong') {
|
||||
this.logger.log('✅ Redis connection successful (set/get test passed).');
|
||||
} else {
|
||||
this.logger.error(`❌ Redis connection test failed. Expected 'pong', got '${result}'.`);
|
||||
}
|
||||
|
||||
// 4. Clean up the test key
|
||||
await this.cacheManager.del('ping');
|
||||
} catch (error) {
|
||||
this.logger.error('❌ Failed to connect to Redis during ping test:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
|
||||
|
||||
async get<T>(key: string) {
|
||||
return await this.cacheManager.get<T>(key);
|
||||
}
|
||||
|
||||
async set(key: string, value: string, ttl: number = 160) {
|
||||
await this.cacheManager.set(key, value, ttl * 1000);
|
||||
}
|
||||
|
||||
async del(key: string) {
|
||||
await this.cacheManager.del(key);
|
||||
}
|
||||
|
||||
async getTtl(key: string) {
|
||||
return await this.cacheManager.ttl(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// // src/modules/cache/cache.service.ts
|
||||
// import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
|
||||
// import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
// @Injectable()
|
||||
// export class CacheService implements OnModuleInit {
|
||||
// private readonly logger = new Logger(CacheService.name);
|
||||
|
||||
// constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
|
||||
|
||||
// /**
|
||||
// * On-init lifecycle hook to test the cache connection.
|
||||
// */
|
||||
// async onModuleInit() {
|
||||
// this.logger.log('Pinging Redis to check connection...');
|
||||
// const testKey = 'ping';
|
||||
// const testValue = 'pong';
|
||||
|
||||
// try {
|
||||
// // 1. Set test key with a 10-second TTL
|
||||
// // Note: We use the wrapper to ensure consistent TTL logic (seconds -> ms)
|
||||
// await this.set(testKey, testValue, 10);
|
||||
|
||||
// // 2. Get test key
|
||||
// const result = await this.get(testKey);
|
||||
|
||||
// if (result === testValue) {
|
||||
// this.logger.log('✅ Redis connection successful (set/get test passed).');
|
||||
// } else {
|
||||
// this.logger.error(
|
||||
// `❌ Redis connection test failed. Expected '${testValue}', got '${result}'.`,
|
||||
// );
|
||||
// }
|
||||
|
||||
// // 3. Clean up the test key
|
||||
// await this.del(testKey);
|
||||
// } catch (error) {
|
||||
// this.logger.error(
|
||||
// '❌ Failed to connect to Redis during ping test:',
|
||||
// error.message,
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Gets a value from the cache.
|
||||
// * @param key The cache key.
|
||||
// * @returns The cached value or undefined.
|
||||
// */
|
||||
// async get<T>(key: string): Promise<T | undefined> {
|
||||
// try {
|
||||
// return await this.cacheManager.get<T>(key);
|
||||
// } catch (error) {
|
||||
// this.logger.error(`Error getting from cache (key: ${key}):`, error.message);
|
||||
// return undefined; // Fail gracefully
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Sets a value in the cache.
|
||||
// * @param key The cache key.
|
||||
// * @param value The value to store (can be any serializable type).
|
||||
// * @param ttlInSeconds The time-to-live in **seconds**.
|
||||
// */
|
||||
// async set(key: string, value: unknown, ttlInSeconds: number = 160): Promise<void> {
|
||||
// try {
|
||||
// // Convert seconds to milliseconds for cache-manager
|
||||
// await this.cacheManager.set(key, value, ttlInSeconds * 1000);
|
||||
// } catch (error) {
|
||||
// this.logger.error(`Error setting cache (key: ${key}):`, error.message);
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Deletes a value from the cache.
|
||||
// * @param key The cache key to delete.
|
||||
// */
|
||||
// async del(key: string): Promise<void> {
|
||||
// try {
|
||||
// await this.cacheManager.del(key);
|
||||
// } catch (error)
|
||||
// {
|
||||
// this.logger.error(`Error deleting from cache (key: ${key}):`, error.message);
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Gets the remaining TTL for a key in seconds.
|
||||
// * Note: This implementation is specific to redis-store.
|
||||
// * @param key The cache key.
|
||||
// * @returns The remaining TTL in seconds, or -1 (no expiry) / -2 (key doesn't exist).
|
||||
// */
|
||||
// async getTtl(key: string): Promise<number | undefined> {
|
||||
// try {
|
||||
// // The `ttl` method from cache-manager-redis-store returns TTL in seconds
|
||||
// return await this.cacheManager.ttl(key);
|
||||
// } catch (error) {
|
||||
// this.logger.error(`Error getting TTL (key: ${key}):`, error.message);
|
||||
// return undefined;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||
// import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
// import { lastValueFrom } from 'rxjs';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface IParameterArray {
|
||||
name: 'otp';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ISmsResponse {
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ISmsVerifyBody {
|
||||
Parameters: IParameterArray[];
|
||||
Mobile: string;
|
||||
TemplateId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private readonly baseURL: string;
|
||||
private readonly OTP: string;
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(
|
||||
// private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.baseURL = this.configService.getOrThrow<string>('SMS_BASE_URL');
|
||||
this.OTP = this.configService.getOrThrow<string>('SMS_PATTERN_OTP');
|
||||
this.apiKey = this.configService.getOrThrow<string>('SMS_API_KEY');
|
||||
}
|
||||
|
||||
async sendOtp(mobile: string, otp: string) {
|
||||
const smsData: ISmsVerifyBody = {
|
||||
Parameters: [{ name: 'otp', value: otp }],
|
||||
Mobile: mobile,
|
||||
TemplateId: this.OTP,
|
||||
};
|
||||
|
||||
const url = `${this.baseURL}/send/verify`;
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<ISmsResponse>(url, smsData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-KEY': this.apiKey,
|
||||
},
|
||||
});
|
||||
if (data.status !== 1) {
|
||||
throw new HttpException('The SMS was not sent. Please try again later.', HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('SMS sending failed:', JSON.stringify(error));
|
||||
throw new HttpException('The SMS was not sent. Please try again later.', HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CacheService } from './cache.service';
|
||||
import { SmsService } from './sms.service';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { cacheConfig } from '../../config/cache.config';
|
||||
|
||||
@Module({
|
||||
imports: [CacheModule.registerAsync(cacheConfig())],
|
||||
controllers: [],
|
||||
providers: [CacheService, SmsService],
|
||||
exports: [CacheService, SmsService],
|
||||
})
|
||||
export class UtilsModule {}
|
||||
Reference in New Issue
Block a user