init
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile*
|
||||
coverage
|
||||
*.local
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /backend
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY package*.json .
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["npm", "run", "start:dev"]
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /backend
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY package*.json .
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
CMD ["node", "dist/main"]
|
||||
@@ -0,0 +1,44 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['eslint.config.mjs']
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true
|
||||
}
|
||||
],
|
||||
'prettier/prettier': ['error', { endOfLine: 'auto' }]
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
"modules/storefrontCore/views/**/*.hbs",
|
||||
"modules/storefrontCore/public/**/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+11388
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "nobswebdev",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch --watchAssets",
|
||||
"start:prod": "node dist/main",
|
||||
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli",
|
||||
"typeorm:run-migrations": "npm run typeorm migration:run -- -d ./src/database/DataSource.ts",
|
||||
"typeorm:generate-migration": "npm run typeorm -- -d ./src/database/DataSource.ts migration:generate ./src/database/migrations/$npm_config_name",
|
||||
"typeorm:revert-migration": "npm run typeorm -- -d ./src/database/DataSource.ts migration:revert",
|
||||
"typeorm:create-migration": "npm run typeorm -- migration:create ./src/database/migrations/$npm_config_name",
|
||||
"truncate-db": "ts-node -r tsconfig-paths/register ./src/database/utils/drop",
|
||||
"setup-fresh-db": "npm run truncate-db && npm run typeorm:run-migrations",
|
||||
"test": "jest"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"axios": "^1.16.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"content-disposition": "^2.0.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dayjs": "^1.11.20",
|
||||
"decimal.js": "^10.6.0",
|
||||
"hbs": "^4.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.1.1",
|
||||
"pg": "^8.20.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"typeorm": "^0.3.28",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "12.0.0-alpha.6",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/hbs": "^4.0.5",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^30.4.2",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService, registerAs } from '@nestjs/config';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import {
|
||||
getAppConfig,
|
||||
getCoingeckoConfig,
|
||||
getEncryptionConfig,
|
||||
getJwtConfig,
|
||||
getKrakenConfig,
|
||||
getOrderConfig,
|
||||
getInvoiceConfig,
|
||||
getMoneroWalletConfig,
|
||||
getPostgresConfig,
|
||||
getShopSettingsConfig,
|
||||
getSimplexConfig
|
||||
} from './config';
|
||||
import { validate } from './config/validate';
|
||||
import { AuthModule } from './modules/auth/AuthModule';
|
||||
import { EncryptionModule } from './modules/encryption/EncryptionModule';
|
||||
import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule';
|
||||
import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule';
|
||||
import { SimplexModule } from './modules/simplex/SimplexModule';
|
||||
import { DiscountCodesModule } from './modules/discountCode/DiscountCodesModule';
|
||||
import { DataWipeModule } from './modules/dataWipe/DataWipeModule';
|
||||
import { NotificationsModule } from './modules/notifications/NotificationsModule';
|
||||
import { OrderModule } from './modules/order/OrderModule';
|
||||
import { PaymentModule } from './modules/payment/PaymentModule';
|
||||
import { ProductsModule } from './modules/product/ProductsModule';
|
||||
import { ShopSettingsModule } from './modules/shopSettings/ShopSettingsModule';
|
||||
import { StorefrontCartModule } from './modules/storefrontCart/StorefrontCartModule';
|
||||
import { StorefrontCheckoutModule } from './modules/storefrontCheckout/StorefrontCheckoutModule';
|
||||
import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule';
|
||||
import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule';
|
||||
import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule';
|
||||
import { XmrRateModule } from './modules/xmrRate/XmrRateModule';
|
||||
import { Config } from './types/Config';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate,
|
||||
load: [
|
||||
registerAs('postgres', getPostgresConfig),
|
||||
registerAs('app', getAppConfig),
|
||||
registerAs('jwt', getJwtConfig),
|
||||
registerAs('coingecko', getCoingeckoConfig),
|
||||
registerAs('kraken', getKrakenConfig),
|
||||
registerAs('encryption', getEncryptionConfig),
|
||||
registerAs('shopSettings', getShopSettingsConfig),
|
||||
registerAs('order', getOrderConfig),
|
||||
registerAs('invoice', getInvoiceConfig),
|
||||
registerAs('moneroWallet', getMoneroWalletConfig),
|
||||
registerAs('simplex', getSimplexConfig)
|
||||
]
|
||||
}),
|
||||
ThrottlerModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const { throttle } = configService.get('app') as Config['app'];
|
||||
|
||||
return {
|
||||
throttlers: [
|
||||
{
|
||||
name: 'default',
|
||||
ttl: throttle.ttlMs,
|
||||
limit: throttle.limit
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}),
|
||||
XmrRateModule,
|
||||
TypeOrmModule.forRootAsync({
|
||||
useFactory: (configService: ConfigService) => configService.get('postgres') as Config['postgres'],
|
||||
inject: [ConfigService]
|
||||
}),
|
||||
HealthCheckModule,
|
||||
EncryptionModule,
|
||||
AuthModule,
|
||||
ProductsModule,
|
||||
ShopSettingsModule,
|
||||
SimplexModule,
|
||||
NotificationsModule,
|
||||
DiscountCodesModule,
|
||||
OrderModule,
|
||||
PaymentModule,
|
||||
DataWipeModule,
|
||||
MoneroWalletModule,
|
||||
StorefrontCoreModule,
|
||||
StorefrontProductModule,
|
||||
StorefrontCartModule,
|
||||
StorefrontCheckoutModule,
|
||||
StorefrontOrderModule
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard
|
||||
}
|
||||
]
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
AppConfig,
|
||||
CoingeckoConfig,
|
||||
EncryptionConfig,
|
||||
InvoiceConfig,
|
||||
JwtConfig,
|
||||
KrakenConfig,
|
||||
MulterConfig,
|
||||
OrderConfig,
|
||||
PostgresConfig,
|
||||
ShopSettingsConfig
|
||||
} from '../types/Config';
|
||||
import { MoneroNetwork } from '../types/MoneroNetwork';
|
||||
import { MoneroWalletConfig } from '../types/MoneroWalletConfig';
|
||||
import { MoneroConfirmationTier } from '../types/MoneroConfirmationTier';
|
||||
import { NodeEnv } from '../types/NodeEnv';
|
||||
import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
|
||||
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
|
||||
import { SimplexConfig } from '../types/SimplexConfig';
|
||||
|
||||
const env = (key: string): string => process.env[key] || '';
|
||||
|
||||
const envInt = (key: string): number => parseInt(env(key), 10);
|
||||
|
||||
const isEnabled = (key: string): boolean => env(key) === 'true';
|
||||
|
||||
export const getPostgresConfig = (): PostgresConfig => {
|
||||
return {
|
||||
type: 'postgres',
|
||||
url: `postgres://${env('POSTGRES_USER')}:${env('POSTGRES_PASSWORD')}@${env('POSTGRES_HOST')}:${env('POSTGRES_PORT')}/${env('POSTGRES_DB')}`,
|
||||
host: env('POSTGRES_HOST'),
|
||||
username: env('POSTGRES_USER'),
|
||||
password: env('POSTGRES_PASSWORD'),
|
||||
port: envInt('POSTGRES_PORT'),
|
||||
database: env('POSTGRES_DB'),
|
||||
entities: [__dirname + '/../modules/**/entities/*{.ts,.js}'],
|
||||
migrations: [__dirname + '/../database/migrations/*{.ts,.js}'],
|
||||
migrationsRun: isEnabled('POSTGRES_MIGRATIONS_RUN')
|
||||
};
|
||||
};
|
||||
|
||||
export const getAppConfig = (): AppConfig => {
|
||||
const rawOrigins = env('CORS_ORIGINS');
|
||||
|
||||
return {
|
||||
port: envInt('BACKEND_PORT'),
|
||||
nodeEnv: env('NODE_ENV') as NodeEnv,
|
||||
corsOrigins: rawOrigins
|
||||
.split(',')
|
||||
.map(o => o.trim())
|
||||
.filter(Boolean),
|
||||
cmsPassword: env('CMS_PASSWORD'),
|
||||
captcha: {
|
||||
length: envInt('CAPTCHA_LENGTH')
|
||||
},
|
||||
throttle: {
|
||||
ttlMs: envInt('THROTTLE_TTL_MS'),
|
||||
limit: envInt('THROTTLE_LIMIT')
|
||||
},
|
||||
signedCookie: {
|
||||
jwtSecret: env('SIGNED_COOKIE_JWT_SECRET'),
|
||||
feedback: {
|
||||
cookieName: env('SIGNED_COOKIE_FEEDBACK_NAME'),
|
||||
expiresInMs: envInt('SIGNED_COOKIE_FEEDBACK_EXPIRES_IN_MS')
|
||||
},
|
||||
cart: {
|
||||
cookieName: env('SIGNED_COOKIE_CART_NAME'),
|
||||
expiresInMs: envInt('SIGNED_COOKIE_CART_EXPIRES_IN_MS')
|
||||
},
|
||||
captcha: {
|
||||
cookieName: env('SIGNED_COOKIE_CAPTCHA_NAME'),
|
||||
expiresInMs: envInt('SIGNED_COOKIE_CAPTCHA_EXPIRES_IN_MS')
|
||||
},
|
||||
discount: {
|
||||
cookieName: env('SIGNED_COOKIE_DISCOUNT_NAME'),
|
||||
expiresInMs: envInt('SIGNED_COOKIE_DISCOUNT_EXPIRES_IN_MS')
|
||||
},
|
||||
error: {
|
||||
cookieName: env('SIGNED_COOKIE_ERROR_NAME'),
|
||||
expiresInMs: envInt('SIGNED_COOKIE_ERROR_EXPIRES_IN_MS')
|
||||
},
|
||||
checkoutSession: {
|
||||
cookieName: env('SIGNED_COOKIE_CHECKOUT_SESSION_NAME'),
|
||||
expiresInMs: envInt('ORDER_CHECKOUT_VALIDITY_MS')
|
||||
},
|
||||
orderAuth: {
|
||||
cookieName: env('SIGNED_COOKIE_ORDER_AUTH_NAME'),
|
||||
expiresInMs: envInt('SIGNED_COOKIE_ORDER_AUTH_EXPIRES_IN_MS')
|
||||
},
|
||||
theme: {
|
||||
cookieName: env('SIGNED_COOKIE_THEME_NAME'),
|
||||
expiresInMs: envInt('SIGNED_COOKIE_THEME_EXPIRES_IN_MS')
|
||||
}
|
||||
},
|
||||
validation: {
|
||||
productTitleMaxLength: envInt('VALIDATION_PRODUCT_TITLE_MAX_LENGTH'),
|
||||
categoryNameMaxLength: envInt('VALIDATION_CATEGORY_NAME_MAX_LENGTH'),
|
||||
discountCodeMaxLength: envInt('VALIDATION_DISCOUNT_CODE_MAX_LENGTH'),
|
||||
variantImagesMax: envInt('VALIDATION_VARIANT_IMAGES_MAX'),
|
||||
digitalStockAttachmentsMax: envInt('VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'),
|
||||
shippingNoteMinLength: envInt('VALIDATION_SHIPPING_NOTE_MIN_LENGTH'),
|
||||
shippingNoteMaxLength: envInt('VALIDATION_SHIPPING_NOTE_MAX_LENGTH'),
|
||||
orderMessageMaxLength: envInt('VALIDATION_ORDER_MESSAGE_MAX_LENGTH')
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const getJwtConfig = (): JwtConfig => {
|
||||
return {
|
||||
secret: env('JWT_SECRET'),
|
||||
expiresInMs: envInt('JWT_EXPIRES_IN_MS')
|
||||
};
|
||||
};
|
||||
|
||||
export const getProductThumbMulterConfig = (): MulterConfig => {
|
||||
const allowedMimes = env('MULTER_PRODUCT_THUMB_ALLOWED_MIMES')
|
||||
.split(',')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const maxFileBytes = envInt('MULTER_PRODUCT_THUMB_MAX_FILE_BYTES');
|
||||
|
||||
return { allowedMimes, maxFileBytes };
|
||||
};
|
||||
|
||||
export const getShopLogoMulterConfig = (): MulterConfig => {
|
||||
const allowedMimes = env('MULTER_SHOP_LOGO_ALLOWED_MIMES')
|
||||
.split(',')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const maxFileBytes = envInt('MULTER_SHOP_LOGO_MAX_FILE_BYTES');
|
||||
|
||||
return { allowedMimes, maxFileBytes };
|
||||
};
|
||||
|
||||
export const getShopFaviconMulterConfig = (): MulterConfig => {
|
||||
const allowedMimes = env('MULTER_SHOP_FAVICON_ALLOWED_MIMES')
|
||||
.split(',')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const maxFileBytes = envInt('MULTER_SHOP_FAVICON_MAX_FILE_BYTES');
|
||||
|
||||
return { allowedMimes, maxFileBytes };
|
||||
};
|
||||
|
||||
export const getDigitalStockAttachmentMulterConfig = (): MulterConfig => {
|
||||
const allowedMimes = env('MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES')
|
||||
.split(',')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const maxFileBytes = envInt('MULTER_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES');
|
||||
|
||||
return { allowedMimes, maxFileBytes };
|
||||
};
|
||||
|
||||
export const getCoingeckoConfig = (): CoingeckoConfig => {
|
||||
return {
|
||||
apiBaseUrl: env('COINGECKO_API_BASE_URL'),
|
||||
xmrRateFetchTimeoutMs: envInt('COINGECKO_XMR_RATE_FETCH_TIMEOUT_MS')
|
||||
};
|
||||
};
|
||||
|
||||
export const getKrakenConfig = (): KrakenConfig => {
|
||||
return {
|
||||
apiBaseUrl: env('KRAKEN_API_BASE_URL'),
|
||||
xmrRateFetchTimeoutMs: envInt('KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS')
|
||||
};
|
||||
};
|
||||
|
||||
export const getEncryptionConfig = (): EncryptionConfig => ({
|
||||
keyBase64: env('BASE64_ENCRYPTION_KEY')
|
||||
});
|
||||
|
||||
export const getShopSettingsConfig = (): ShopSettingsConfig => ({
|
||||
shopName: env('SHOP_NAME'),
|
||||
shopFiatCurrency: env('SHOP_FIAT_CURRENCY') as ShopFiatCurrency,
|
||||
monero: {
|
||||
confirmationTiers: JSON.parse(env('MONERO_CONFIRMATION_TIERS')) as MoneroConfirmationTier[]
|
||||
}
|
||||
});
|
||||
|
||||
export const getOrderConfig = (): OrderConfig => ({
|
||||
checkoutValidityMs: envInt('ORDER_CHECKOUT_VALIDITY_MS'),
|
||||
shippingPaymentValidityMs: envInt('ORDER_SHIPPING_PAYMENT_VALIDITY_MS'),
|
||||
checkoutStatusRefreshSec: envInt('ORDER_CHECKOUT_STATUS_REFRESH_SEC'),
|
||||
dataRetentionDays: envInt('ORDER_DATA_RETENTION_DAYS')
|
||||
});
|
||||
|
||||
export const getInvoiceConfig = (): InvoiceConfig => ({
|
||||
minByMethod: {
|
||||
[PaymentMethod.Xmr]: String(envInt('MONERO_MIN_INCOMING_ATOMIC'))
|
||||
}
|
||||
});
|
||||
|
||||
export const getMoneroWalletConfig = (): MoneroWalletConfig => ({
|
||||
network: env('MONERO_NETWORK') as MoneroNetwork,
|
||||
rpcUrl: `http://${env('MONERO_WALLET_RPC_HOST')}:${envInt('MONERO_WALLET_RPC_PORT')}/json_rpc`,
|
||||
daemonRpcUrl: `http://${env('MONERO_DAEMON_ADDRESS')}/json_rpc`,
|
||||
username: env('MONERO_WALLET_RPC_USERNAME'),
|
||||
password: env('MONERO_WALLET_RPC_PASSWORD'),
|
||||
rpcTimeoutMs: envInt('MONERO_WALLET_RPC_TIMEOUT_MS')
|
||||
});
|
||||
|
||||
export const getSimplexConfig = (): SimplexConfig => ({
|
||||
wsUrl: env('SIMPLEX_WS_URL'),
|
||||
botDisplayName: env('SIMPLEX_BOT_DISPLAY_NAME')
|
||||
});
|
||||
|
||||
export default () => ({
|
||||
postgres: getPostgresConfig(),
|
||||
app: getAppConfig(),
|
||||
jwt: getJwtConfig(),
|
||||
coingecko: getCoingeckoConfig(),
|
||||
kraken: getKrakenConfig(),
|
||||
encryption: getEncryptionConfig(),
|
||||
shopSettings: getShopSettingsConfig(),
|
||||
order: getOrderConfig(),
|
||||
invoice: getInvoiceConfig(),
|
||||
moneroWallet: getMoneroWalletConfig(),
|
||||
simplex: getSimplexConfig()
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getAppConfig } from './index';
|
||||
|
||||
const {
|
||||
throttle: { ttlMs }
|
||||
} = getAppConfig();
|
||||
|
||||
export const throttleProfiles = {
|
||||
checkOrder: { default: { limit: 10, ttl: ttlMs } },
|
||||
cmsLogin: { default: { limit: 5, ttl: ttlMs } },
|
||||
walletWithdraw: { default: { limit: 5, ttl: ttlMs } },
|
||||
walletRevealSeed: { default: { limit: 5, ttl: ttlMs } },
|
||||
checkoutPay: { default: { limit: 5, ttl: ttlMs } },
|
||||
cartDiscount: { default: { limit: 10, ttl: ttlMs } },
|
||||
cartPage: { default: { limit: 40, ttl: ttlMs } }
|
||||
} as const;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
|
||||
const uploadsRoot = (): string => join(process.cwd(), 'uploads');
|
||||
|
||||
const validatePathTraversal = (baseDir: string, absolutePath: string): void => {
|
||||
const resolvedBaseDir = resolve(baseDir);
|
||||
|
||||
if (!absolutePath.startsWith(`${resolvedBaseDir}${sep}`)) {
|
||||
throw new Error('Invalid storage key');
|
||||
}
|
||||
};
|
||||
|
||||
const resolvePathWithinDir = (baseDir: string, storageKey: string): string => {
|
||||
const resolvedBaseDir = resolve(baseDir);
|
||||
const absolutePath = resolve(resolvedBaseDir, storageKey);
|
||||
|
||||
validatePathTraversal(resolvedBaseDir, absolutePath);
|
||||
|
||||
return absolutePath;
|
||||
};
|
||||
|
||||
export const getPublicUploadsDir = (): string => join(uploadsRoot(), 'public');
|
||||
|
||||
export const publicUploadsUrlPrefix = '/uploads/public/';
|
||||
|
||||
export const getPrivateUploadsDir = (): string => join(uploadsRoot(), 'private');
|
||||
|
||||
export const getVariantImagesDir = (): string => join(getPublicUploadsDir(), 'variants', 'images');
|
||||
|
||||
export const variantImagesUrlPrefix = '/uploads/public/variants/images/';
|
||||
|
||||
export const getVariantImagePublicUrl = (filename: string): string => `${variantImagesUrlPrefix}${filename}`;
|
||||
|
||||
export const getShopBrandingDir = (): string => join(getPublicUploadsDir(), 'shop');
|
||||
|
||||
export const shopBrandingUrlPrefix = '/uploads/public/shop/';
|
||||
|
||||
export const getShopBrandingPublicUrl = (filename: string): string => `${shopBrandingUrlPrefix}${filename}`;
|
||||
|
||||
export const resolveShopBrandingPath = (storageKey: string): string => {
|
||||
const baseDir = getShopBrandingDir();
|
||||
|
||||
const absolutePath = resolvePathWithinDir(baseDir, storageKey);
|
||||
|
||||
return absolutePath;
|
||||
};
|
||||
|
||||
export const getDigitalStockAttachmentsDir = (): string => join(getPrivateUploadsDir(), 'digital-stock', 'attachments');
|
||||
|
||||
export const resolveDigitalStockAttachmentPath = (storageKey: string): string => {
|
||||
const baseDir = getDigitalStockAttachmentsDir();
|
||||
|
||||
const absolutePath = resolvePathWithinDir(baseDir, storageKey);
|
||||
|
||||
return absolutePath;
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min, validateSync } from 'class-validator';
|
||||
import { NodeEnv } from '../types/NodeEnv';
|
||||
import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
|
||||
import { IsBase64 } from '../validation/decorators/isBase64';
|
||||
import { IsMoneroConfirmationTiers } from '../validation/decorators/isMoneroConfirmationTiers';
|
||||
import { MoneroNetwork } from '../types/MoneroNetwork';
|
||||
|
||||
class EnvironmentVariables {
|
||||
@IsNotEmpty()
|
||||
@IsEnum(NodeEnv)
|
||||
NODE_ENV: NodeEnv;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
BACKEND_PORT: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
POSTGRES_USER: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
POSTGRES_PASSWORD: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
POSTGRES_DB: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
POSTGRES_HOST: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
POSTGRES_PORT: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsBoolean()
|
||||
POSTGRES_MIGRATIONS_RUN: boolean;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
CORS_ORIGINS: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
JWT_SECRET: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
JWT_EXPIRES_IN_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
CMS_PASSWORD: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
SHOP_NAME: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsEnum(ShopFiatCurrency)
|
||||
SHOP_FIAT_CURRENCY: ShopFiatCurrency;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
MULTER_PRODUCT_THUMB_ALLOWED_MIMES: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10 * 1024 * 1024) // 10MB
|
||||
MULTER_PRODUCT_THUMB_MAX_FILE_BYTES: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
MULTER_SHOP_LOGO_ALLOWED_MIMES: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10 * 1024 * 1024) // 10MB
|
||||
MULTER_SHOP_LOGO_MAX_FILE_BYTES: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
MULTER_SHOP_FAVICON_ALLOWED_MIMES: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10 * 1024 * 1024) // 10MB
|
||||
MULTER_SHOP_FAVICON_MAX_FILE_BYTES: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(256)
|
||||
VALIDATION_PRODUCT_TITLE_MAX_LENGTH: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(256)
|
||||
VALIDATION_CATEGORY_NAME_MAX_LENGTH: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(64)
|
||||
VALIDATION_DISCOUNT_CODE_MAX_LENGTH: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
VALIDATION_VARIANT_IMAGES_MAX: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(50 * 1024 * 1024) // 50MB
|
||||
MULTER_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(20)
|
||||
VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
VALIDATION_SHIPPING_NOTE_MIN_LENGTH: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10000)
|
||||
VALIDATION_SHIPPING_NOTE_MAX_LENGTH: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10000)
|
||||
VALIDATION_ORDER_MESSAGE_MAX_LENGTH: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(4)
|
||||
@Max(8)
|
||||
CAPTCHA_LENGTH: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1000)
|
||||
@Max(3_600_000)
|
||||
THROTTLE_TTL_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10_000)
|
||||
THROTTLE_LIMIT: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
SIGNED_COOKIE_JWT_SECRET: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
SIGNED_COOKIE_FEEDBACK_NAME: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
SIGNED_COOKIE_FEEDBACK_EXPIRES_IN_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
SIGNED_COOKIE_CART_NAME: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
SIGNED_COOKIE_CART_EXPIRES_IN_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
SIGNED_COOKIE_CAPTCHA_NAME: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
SIGNED_COOKIE_CAPTCHA_EXPIRES_IN_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
SIGNED_COOKIE_DISCOUNT_NAME: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
SIGNED_COOKIE_DISCOUNT_EXPIRES_IN_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
SIGNED_COOKIE_ERROR_NAME: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
SIGNED_COOKIE_ERROR_EXPIRES_IN_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
SIGNED_COOKIE_CHECKOUT_SESSION_NAME: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
SIGNED_COOKIE_ORDER_AUTH_NAME: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
SIGNED_COOKIE_ORDER_AUTH_EXPIRES_IN_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
SIGNED_COOKIE_THEME_NAME: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
SIGNED_COOKIE_THEME_EXPIRES_IN_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
COINGECKO_API_BASE_URL: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
KRAKEN_API_BASE_URL: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
COINGECKO_XMR_RATE_FETCH_TIMEOUT_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@IsBase64({ byteLength: 32 })
|
||||
BASE64_ENCRYPTION_KEY: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@IsMoneroConfirmationTiers()
|
||||
MONERO_CONFIRMATION_TIERS: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(60000)
|
||||
ORDER_CHECKOUT_VALIDITY_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
ORDER_CHECKOUT_STATUS_REFRESH_SEC: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(60000)
|
||||
ORDER_SHIPPING_PAYMENT_VALIDITY_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
ORDER_DATA_RETENTION_DAYS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(Number.MAX_SAFE_INTEGER)
|
||||
MONERO_MIN_INCOMING_ATOMIC: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
MONERO_DAEMON_ADDRESS: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsEnum(MoneroNetwork)
|
||||
MONERO_NETWORK: MoneroNetwork;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
MONERO_WALLET_RPC_HOST: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
MONERO_WALLET_RPC_PORT: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
MONERO_WALLET_RPC_USERNAME: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
MONERO_WALLET_RPC_PASSWORD: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1000)
|
||||
MONERO_WALLET_RPC_TIMEOUT_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
SIMPLEX_WS_URL: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
SIMPLEX_BOT_DISPLAY_NAME: string;
|
||||
}
|
||||
|
||||
export const validate = (config: Record<string, unknown>) => {
|
||||
const validatedConfig = plainToClass(EnvironmentVariables, config, {
|
||||
enableImplicitConversion: true
|
||||
});
|
||||
|
||||
const errors = validateSync(validatedConfig, {
|
||||
skipMissingProperties: false
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.toString());
|
||||
}
|
||||
|
||||
return validatedConfig;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const SHOP_SURFACE_HEADER_NAME = 'x-shop-surface';
|
||||
@@ -0,0 +1,5 @@
|
||||
export const StorefrontOrderPageAnchor = {
|
||||
Chat: 'order-chat',
|
||||
CheckoutPayment: 'order-checkout-payment',
|
||||
ShippingPayment: 'order-shipping-payment'
|
||||
} as const;
|
||||
@@ -0,0 +1,5 @@
|
||||
export const StorefrontOrderRefreshSection = {
|
||||
Chat: 'chat',
|
||||
CheckoutPayment: 'checkout-payment',
|
||||
ShippingPayment: 'shipping-payment'
|
||||
} as const;
|
||||
@@ -0,0 +1,3 @@
|
||||
import Decimal from 'decimal.js';
|
||||
|
||||
export const XMR_ATOMIC_PER_XMR = new Decimal(1_000_000_000_000);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { getPostgresConfig } from '../config';
|
||||
|
||||
export default new DataSource(getPostgresConfig());
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class InitProductsCrud1777811112018 implements MigrationInterface {
|
||||
name = 'InitProductsCrud1777811112018';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "digital_stock_items" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "content" text NOT NULL, "isSold" boolean NOT NULL DEFAULT false, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, "productId" uuid, CONSTRAINT "PK_bfffac39999d57b14d445abeb2f" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(`CREATE TYPE "public"."products_priceunit_enum" AS ENUM('USD')`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."products_type_enum" AS ENUM('digital')`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "products" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "price" numeric(12,2) NOT NULL, "priceUnit" "public"."products_priceunit_enum" NOT NULL DEFAULT 'USD', "type" "public"."products_type_enum" NOT NULL DEFAULT 'digital', "thumbnailUrl" character varying NOT NULL, "descriptionHtml" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, CONSTRAINT "PK_0806c755e0aca124e67c0cf6d7d" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "digital_stock_items" ADD CONSTRAINT "FK_f3ee7be0d350b6954b5d3c8bf32" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "digital_stock_items" DROP CONSTRAINT "FK_f3ee7be0d350b6954b5d3c8bf32"`);
|
||||
await queryRunner.query(`DROP TABLE "products"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."products_type_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."products_priceunit_enum"`);
|
||||
await queryRunner.query(`DROP TABLE "digital_stock_items"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class MakeProductAsDraftInitialy1778247115870 implements MigrationInterface {
|
||||
name = 'MakeProductAsDraftInitialy1778247115870';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "products" ADD "isDraft" boolean NOT NULL DEFAULT true`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "title" SET DEFAULT ''`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "price" SET DEFAULT '0'`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "thumbnailUrl" SET DEFAULT ''`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "descriptionHtml" SET DEFAULT ''`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "descriptionHtml" DROP DEFAULT`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "thumbnailUrl" DROP DEFAULT`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "price" DROP DEFAULT`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "title" DROP DEFAULT`);
|
||||
await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "isDraft"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPhysicalProductType1779658508834 implements MigrationInterface {
|
||||
name = 'AddPhysicalProductType1779658508834';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "products" ADD "stockQuantity" integer`);
|
||||
await queryRunner.query(`ALTER TYPE "public"."products_type_enum" RENAME TO "products_type_enum_old"`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."products_type_enum" AS ENUM('digital', 'physical')`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "type" DROP DEFAULT`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "products" ALTER COLUMN "type" TYPE "public"."products_type_enum" USING "type"::"text"::"public"."products_type_enum"`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "type" SET DEFAULT 'digital'`);
|
||||
await queryRunner.query(`DROP TYPE "public"."products_type_enum_old"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TYPE "public"."products_type_enum_old" AS ENUM('digital')`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "type" DROP DEFAULT`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "products" ALTER COLUMN "type" TYPE "public"."products_type_enum_old" USING "type"::"text"::"public"."products_type_enum_old"`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "type" SET DEFAULT 'digital'`);
|
||||
await queryRunner.query(`DROP TYPE "public"."products_type_enum"`);
|
||||
await queryRunner.query(`ALTER TYPE "public"."products_type_enum_old" RENAME TO "products_type_enum"`);
|
||||
await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "stockQuantity"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RemovePriceUnitProductCol1779721786152 implements MigrationInterface {
|
||||
name = 'RemovePriceUnitProductCol1779721786152';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "priceUnit"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."products_priceunit_enum"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TYPE "public"."products_priceunit_enum" AS ENUM('USD')`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "products" ADD "priceUnit" "public"."products_priceunit_enum" NOT NULL DEFAULT 'USD'`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddDiscountCodeEntity1780672489601 implements MigrationInterface {
|
||||
name = 'AddDiscountCodeEntity1780672489601';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TYPE "public"."discount_codes_type_enum" AS ENUM('percent', 'fixed')`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "discount_codes" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "code" character varying NOT NULL, "type" "public"."discount_codes_type_enum" NOT NULL, "value" numeric(12,2) NOT NULL, "isActive" boolean NOT NULL DEFAULT true, "validFrom" TIMESTAMP WITH TIME ZONE, "validUntil" TIMESTAMP WITH TIME ZONE, "maxRedemptions" integer, "redemptionCount" integer NOT NULL DEFAULT '0', "minOrderAmount" numeric(12,2), "isExclusive" boolean NOT NULL DEFAULT false, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, CONSTRAINT "UQ_b967edd0d46547d4a92b4a1c6b3" UNIQUE ("code"), CONSTRAINT "PK_c0170a28d937472e9ce50fdce17" PRIMARY KEY ("id"))`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "discount_codes"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."discount_codes_type_enum"`);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class UsePartialUniqueIndexInsteadOfUniqueConstraintOnDiscount1781106764300 implements MigrationInterface {
|
||||
name = 'UsePartialUniqueIndexInsteadOfUniqueConstraintOnDiscount1781106764300';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "discount_codes" DROP CONSTRAINT "UQ_b967edd0d46547d4a92b4a1c6b3"`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_discount_codes_code_active" ON "discount_codes" ("code") WHERE "deletedAt" IS NULL`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_discount_codes_code_active"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discount_codes" ADD CONSTRAINT "UQ_b967edd0d46547d4a92b4a1c6b3" UNIQUE ("code")`
|
||||
);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddManyToManyDiscountsToProducts1781195026200 implements MigrationInterface {
|
||||
name = 'AddManyToManyDiscountsToProducts1781195026200';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "discount_codes_products" ("discountCodeId" uuid NOT NULL, "productId" uuid NOT NULL, CONSTRAINT "PK_ddb3803ddbde34f384005c7e83c" PRIMARY KEY ("discountCodeId", "productId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_9c88bd20ae9779f6ffe41989f5" ON "discount_codes_products" ("discountCodeId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_fc3c8861bdc81589f933b5cee5" ON "discount_codes_products" ("productId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discount_codes_products" ADD CONSTRAINT "FK_9c88bd20ae9779f6ffe41989f5a" FOREIGN KEY ("discountCodeId") REFERENCES "discount_codes"("id") ON DELETE CASCADE ON UPDATE CASCADE`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discount_codes_products" ADD CONSTRAINT "FK_fc3c8861bdc81589f933b5cee5e" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discount_codes_products" DROP CONSTRAINT "FK_fc3c8861bdc81589f933b5cee5e"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discount_codes_products" DROP CONSTRAINT "FK_9c88bd20ae9779f6ffe41989f5a"`
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_fc3c8861bdc81589f933b5cee5"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_9c88bd20ae9779f6ffe41989f5"`);
|
||||
await queryRunner.query(`DROP TABLE "discount_codes_products"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddProductCategories1781785655067 implements MigrationInterface {
|
||||
name = 'AddProductCategories1781785655067';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "categories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "sortOrder" integer NOT NULL DEFAULT '0', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_8b0be371d28245da6e4f4b61878" UNIQUE ("name"), CONSTRAINT "PK_24dbc6126a28ff948da33e97d3b" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "products_categories" ("productId" uuid NOT NULL, "categoryId" uuid NOT NULL, CONSTRAINT "PK_ac147b513b3ed1671ef3577e098" PRIMARY KEY ("productId", "categoryId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_b7bcf72f50cb6aca555a72eb63" ON "products_categories" ("productId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_10e0ebf99c4b77d7d726036d8e" ON "products_categories" ("categoryId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "products_categories" ADD CONSTRAINT "FK_b7bcf72f50cb6aca555a72eb630" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "products_categories" ADD CONSTRAINT "FK_10e0ebf99c4b77d7d726036d8ec" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "products_categories" DROP CONSTRAINT "FK_10e0ebf99c4b77d7d726036d8ec"`);
|
||||
await queryRunner.query(`ALTER TABLE "products_categories" DROP CONSTRAINT "FK_b7bcf72f50cb6aca555a72eb630"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_10e0ebf99c4b77d7d726036d8e"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_b7bcf72f50cb6aca555a72eb63"`);
|
||||
await queryRunner.query(`DROP TABLE "products_categories"`);
|
||||
await queryRunner.query(`DROP TABLE "categories"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddDiscountToCategory1781849627565 implements MigrationInterface {
|
||||
name = 'AddDiscountToCategory1781849627565';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "discount_codes_categories" ("discountCodeId" uuid NOT NULL, "categoryId" uuid NOT NULL, CONSTRAINT "PK_a1d001587e1735737d233e956a5" PRIMARY KEY ("discountCodeId", "categoryId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_3126372e862e29a5374a8594b0" ON "discount_codes_categories" ("discountCodeId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_1bcf070115fa8dc5ebb57d0fee" ON "discount_codes_categories" ("categoryId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discount_codes_categories" ADD CONSTRAINT "FK_3126372e862e29a5374a8594b0c" FOREIGN KEY ("discountCodeId") REFERENCES "discount_codes"("id") ON DELETE CASCADE ON UPDATE CASCADE`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discount_codes_categories" ADD CONSTRAINT "FK_1bcf070115fa8dc5ebb57d0fee8" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discount_codes_categories" DROP CONSTRAINT "FK_1bcf070115fa8dc5ebb57d0fee8"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discount_codes_categories" DROP CONSTRAINT "FK_3126372e862e29a5374a8594b0c"`
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_1bcf070115fa8dc5ebb57d0fee"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_3126372e862e29a5374a8594b0"`);
|
||||
await queryRunner.query(`DROP TABLE "discount_codes_categories"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddProductVariantsTable1782151519819 implements MigrationInterface {
|
||||
name = 'AddProductVariantsTable1782151519819';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "product_variants" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "price" numeric(12,2) NOT NULL DEFAULT '0', "stockQuantity" integer, "isDefault" boolean NOT NULL DEFAULT false, "sortOrder" integer NOT NULL DEFAULT '0', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "productId" uuid, CONSTRAINT "PK_281e3f2c55652d6a22c0aa59fd7" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_product_variants_default_per_product" ON "product_variants" ("productId") WHERE "isDefault" = true`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "price"`);
|
||||
await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "stockQuantity"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "product_variants" ADD CONSTRAINT "FK_f515690c571a03400a9876600b5" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "product_variants" DROP CONSTRAINT "FK_f515690c571a03400a9876600b5"`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ADD "stockQuantity" integer`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ADD "price" numeric(12,2) NOT NULL DEFAULT '0'`);
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_product_variants_default_per_product"`);
|
||||
await queryRunner.query(`DROP TABLE "product_variants"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddDiscountCodeVariantsM2m1782652984862 implements MigrationInterface {
|
||||
name = 'AddDiscountCodeVariantsM2m1782652984862';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "discount_codes_variants" ("discountCodeId" uuid NOT NULL, "variantId" uuid NOT NULL, CONSTRAINT "PK_64381aa5f72cc68f7fdea1f43cc" PRIMARY KEY ("discountCodeId", "variantId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_e7995d0deea26f2a89662b81ad" ON "discount_codes_variants" ("discountCodeId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_04bd8f37b20eddfd6fc1c2c8aa" ON "discount_codes_variants" ("variantId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discount_codes_variants" ADD CONSTRAINT "FK_e7995d0deea26f2a89662b81ada" FOREIGN KEY ("discountCodeId") REFERENCES "discount_codes"("id") ON DELETE CASCADE ON UPDATE CASCADE`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discount_codes_variants" ADD CONSTRAINT "FK_04bd8f37b20eddfd6fc1c2c8aa0" FOREIGN KEY ("variantId") REFERENCES "product_variants"("id") ON DELETE CASCADE ON UPDATE CASCADE`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discount_codes_variants" DROP CONSTRAINT "FK_04bd8f37b20eddfd6fc1c2c8aa0"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discount_codes_variants" DROP CONSTRAINT "FK_e7995d0deea26f2a89662b81ada"`
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_04bd8f37b20eddfd6fc1c2c8aa"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_e7995d0deea26f2a89662b81ad"`);
|
||||
await queryRunner.query(`DROP TABLE "discount_codes_variants"`);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class MoveDigitalStockItemsToVariantScope1782821226132 implements MigrationInterface {
|
||||
name = 'MoveDigitalStockItemsToVariantScope1782821226132';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "digital_stock_items" DROP CONSTRAINT "FK_f3ee7be0d350b6954b5d3c8bf32"`);
|
||||
await queryRunner.query(`ALTER TABLE "digital_stock_items" RENAME COLUMN "productId" TO "variantId"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "digital_stock_items" ADD CONSTRAINT "FK_f93fdc4b42cf0410d8bb0c66778" FOREIGN KEY ("variantId") REFERENCES "product_variants"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "digital_stock_items" DROP CONSTRAINT "FK_f93fdc4b42cf0410d8bb0c66778"`);
|
||||
await queryRunner.query(`ALTER TABLE "digital_stock_items" RENAME COLUMN "variantId" TO "productId"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "digital_stock_items" ADD CONSTRAINT "FK_f3ee7be0d350b6954b5d3c8bf32" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RemoveDefaultVariant1782839159240 implements MigrationInterface {
|
||||
name = 'RemoveDefaultVariant1782839159240';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_product_variants_default_per_product"`);
|
||||
await queryRunner.query(`ALTER TABLE "product_variants" DROP COLUMN "isDefault"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "product_variants" ADD "isDefault" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_product_variants_default_per_product" ON "product_variants" ("productId") WHERE ("isDefault" = true)`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddVariantImages1783206651562 implements MigrationInterface {
|
||||
name = 'AddVariantImages1783206651562';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "variant_images" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "url" character varying NOT NULL, "sortOrder" integer NOT NULL DEFAULT '0', "isThumbnail" boolean NOT NULL DEFAULT false, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "variantId" uuid, CONSTRAINT "PK_e12e3f22db0e085fb8cb0a43fb7" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "thumbnailUrl"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "variant_images" ADD CONSTRAINT "FK_a64680112bb0dc25d6cc0747d4c" FOREIGN KEY ("variantId") REFERENCES "product_variants"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "variant_images" DROP CONSTRAINT "FK_a64680112bb0dc25d6cc0747d4c"`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ADD "thumbnailUrl" character varying NOT NULL DEFAULT ''`);
|
||||
await queryRunner.query(`DROP TABLE "variant_images"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ProductTypeRework1783522977172 implements MigrationInterface {
|
||||
name = 'ProductTypeRework1783522977172';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "products" RENAME COLUMN "type" TO "deliveryMode"`);
|
||||
await queryRunner.query(`ALTER TYPE "public"."products_type_enum" RENAME TO "products_deliverymode_enum"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE "public"."products_deliverymode_enum" RENAME TO "products_deliverymode_enum_old"`
|
||||
);
|
||||
await queryRunner.query(`CREATE TYPE "public"."products_deliverymode_enum" AS ENUM('auto', 'manual')`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "deliveryMode" DROP DEFAULT`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "products" ALTER COLUMN "deliveryMode" TYPE "public"."products_deliverymode_enum" USING "deliveryMode"::"text"::"public"."products_deliverymode_enum"`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "deliveryMode" SET DEFAULT 'auto'`);
|
||||
await queryRunner.query(`DROP TYPE "public"."products_deliverymode_enum_old"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TYPE "public"."products_deliverymode_enum_old" AS ENUM('digital', 'physical')`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "deliveryMode" DROP DEFAULT`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "products" ALTER COLUMN "deliveryMode" TYPE "public"."products_deliverymode_enum_old" USING "deliveryMode"::"text"::"public"."products_deliverymode_enum_old"`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "deliveryMode" SET DEFAULT 'digital'`);
|
||||
await queryRunner.query(`DROP TYPE "public"."products_deliverymode_enum"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE "public"."products_deliverymode_enum_old" RENAME TO "products_deliverymode_enum"`
|
||||
);
|
||||
await queryRunner.query(`ALTER TYPE "public"."products_deliverymode_enum" RENAME TO "products_type_enum"`);
|
||||
await queryRunner.query(`ALTER TABLE "products" RENAME COLUMN "deliveryMode" TO "type"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddDigitalStockAttachments1783610487305 implements MigrationInterface {
|
||||
name = 'AddDigitalStockAttachments1783610487305';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "digital_stock_item_attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "storageKey" character varying NOT NULL, "originalFilename" character varying NOT NULL, "mimeType" character varying NOT NULL, "sizeBytes" integer NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "digitalStockItemId" uuid, CONSTRAINT "PK_8ba50fc3e4cc64e54fb7494f5a1" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "digital_stock_item_attachments" ADD CONSTRAINT "FK_0cc31f343c2efcd598b53e9537d" FOREIGN KEY ("digitalStockItemId") REFERENCES "digital_stock_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "digital_stock_item_attachments" DROP CONSTRAINT "FK_0cc31f343c2efcd598b53e9537d"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "digital_stock_item_attachments"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RemoveSoftDeleteCols1783701820648 implements MigrationInterface {
|
||||
name = 'RemoveSoftDeleteCols1783701820648';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_discount_codes_code_active"`);
|
||||
await queryRunner.query(`ALTER TABLE "digital_stock_items" DROP COLUMN "deletedAt"`);
|
||||
await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "deletedAt"`);
|
||||
await queryRunner.query(`ALTER TABLE "discount_codes" DROP COLUMN "deletedAt"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discount_codes" ADD CONSTRAINT "UQ_b967edd0d46547d4a92b4a1c6b3" UNIQUE ("code")`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "discount_codes" DROP CONSTRAINT "UQ_b967edd0d46547d4a92b4a1c6b3"`);
|
||||
await queryRunner.query(`ALTER TABLE "discount_codes" ADD "deletedAt" TIMESTAMP`);
|
||||
await queryRunner.query(`ALTER TABLE "products" ADD "deletedAt" TIMESTAMP`);
|
||||
await queryRunner.query(`ALTER TABLE "digital_stock_items" ADD "deletedAt" TIMESTAMP`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_discount_codes_code_active" ON "discount_codes" ("code") WHERE ("deletedAt" IS NULL)`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddShopSettings1783806278430 implements MigrationInterface {
|
||||
name = 'AddShopSettings1783806278430';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "shop_settings" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "logoStorageKey" character varying, "simplexLink" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_96c0aa2a327de09ebe0fd54ae5a" PRIMARY KEY ("id"))`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "shop_settings"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddShippingNote1783912100000 implements MigrationInterface {
|
||||
name = 'AddShippingNote1783912100000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" ADD "shippingNote" text`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "shippingNote"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCommerceTables1784030873071 implements MigrationInterface {
|
||||
name = 'AddCommerceTables1784030873071';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// --- invoices ---
|
||||
await queryRunner.query(`CREATE TYPE "public"."invoices_reason_enum" AS ENUM('checkout', 'shipping')`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."invoices_paymentmethod_enum" AS ENUM('xmr')`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "invoices" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "reason" "public"."invoices_reason_enum" NOT NULL, "paymentMethod" "public"."invoices_paymentmethod_enum" NOT NULL, "amountFiat" numeric(12,2) NOT NULL, "fiatCurrency" character varying(3) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "paymentAddress" character varying(255) NOT NULL, "expectedTotalAtomic" bigint NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_invoices_payment_address" UNIQUE ("paymentAddress"), CONSTRAINT "PK_invoices" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "invoice_payments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "txHash" character varying(64) NOT NULL, "amountAtomic" bigint NOT NULL, "confirmations" integer NOT NULL DEFAULT '0', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "invoiceId" uuid, CONSTRAINT "UQ_invoice_payments_tx_hash" UNIQUE ("txHash"), CONSTRAINT "PK_invoice_payments" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "invoice_monero_details" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "paymentAddressIndex" integer NOT NULL, "fiatPerXmrAtCreation" numeric(12,2) NOT NULL, "requiredConfirmations" integer NOT NULL, "invoiceId" uuid, CONSTRAINT "UQ_invoice_monero_details_invoice_id" UNIQUE ("invoiceId"), CONSTRAINT "PK_invoice_monero_details" PRIMARY KEY ("id"))`
|
||||
);
|
||||
|
||||
// --- orders ---
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "public"."orders_failurereason_enum" AS ENUM('stock_unavailable', 'discount_exhausted')`
|
||||
);
|
||||
await queryRunner.query(`CREATE TYPE "public"."order_lines_deliverymode_enum" AS ENUM('auto', 'manual')`);
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "public"."order_line_manual_fulfillments_status_enum" AS ENUM('pending', 'fulfilled')`
|
||||
);
|
||||
await queryRunner.query(`CREATE TYPE "public"."order_messages_sender_enum" AS ENUM('buyer', 'staff')`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "orders" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "accessTokenLookup" character varying(64) NOT NULL, "accessToken" text NOT NULL, "failureReason" "public"."orders_failurereason_enum", "accessTokenSavedConfirmedAt" TIMESTAMP WITH TIME ZONE, "quotedAt" TIMESTAMP WITH TIME ZONE, "checkoutSessionId" uuid, "checkoutInvoiceId" uuid, "shippingInvoiceId" uuid, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_43a3cd3d7373e9013a08a6a65b6" UNIQUE ("accessTokenLookup"), CONSTRAINT "UQ_orders_checkout_session_id" UNIQUE ("checkoutSessionId"), CONSTRAINT "UQ_orders_checkout_invoice_id" UNIQUE ("checkoutInvoiceId"), CONSTRAINT "UQ_orders_shipping_invoice_id" UNIQUE ("shippingInvoiceId"), CONSTRAINT "PK_710e2d4957aa5878dfe94e4ac2f" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "order_lines" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "variantId" uuid NOT NULL, "productId" uuid NOT NULL, "productTitle" character varying NOT NULL, "variantTitle" character varying NOT NULL, "thumbnailUrl" character varying, "qty" integer NOT NULL, "unitPriceFiat" numeric(12,2) NOT NULL, "lineSubtotalFiat" numeric(12,2) NOT NULL, "deliveryMode" "public"."order_lines_deliverymode_enum" NOT NULL, "orderId" uuid, CONSTRAINT "PK_order_lines" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "order_discounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "code" character varying(32) NOT NULL, "amountFiat" numeric(12,2) NOT NULL, "orderId" uuid, CONSTRAINT "PK_order_discounts" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "order_line_auto_fulfillment_items" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "sortOrder" integer NOT NULL, "contentSnapshot" text NOT NULL, "sourceDigitalStockItemId" uuid NOT NULL, "orderLineId" uuid, CONSTRAINT "PK_order_line_auto_fulfillment_items" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "order_line_auto_fulfillment_item_attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "storageKey" character varying NOT NULL, "sourceDigitalStockAttachmentId" uuid NOT NULL, "originalFilename" character varying NOT NULL, "mimeType" character varying NOT NULL, "sizeBytes" integer NOT NULL, "itemId" uuid, CONSTRAINT "PK_order_line_auto_fulfillment_item_attachments" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "order_line_manual_fulfillments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "status" "public"."order_line_manual_fulfillments_status_enum" NOT NULL DEFAULT 'pending', "fulfilledAt" TIMESTAMP WITH TIME ZONE, "orderLineId" uuid, CONSTRAINT "REL_order_line_manual_fulfillments_line" UNIQUE ("orderLineId"), CONSTRAINT "PK_order_line_manual_fulfillments" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "order_messages" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "orderId" uuid, "sender" "public"."order_messages_sender_enum" NOT NULL, "body" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_order_messages_id" PRIMARY KEY ("id"))`
|
||||
);
|
||||
|
||||
// --- checkout sessions (kept after order materialization for lookup) ---
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "public"."checkout_session_lines_deliverymode_enum" AS ENUM('auto', 'manual')`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "checkout_session_discounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "code" character varying(32) NOT NULL, "amountFiat" numeric(12,2) NOT NULL, "sessionId" uuid, CONSTRAINT "PK_0378497bbff0d9ff832535f92c7" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "checkout_session_lines" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "variantId" uuid NOT NULL, "productId" uuid NOT NULL, "productTitle" character varying NOT NULL, "variantTitle" character varying NOT NULL, "thumbnailUrl" character varying, "qty" integer NOT NULL, "unitPriceFiat" numeric(12,2) NOT NULL, "lineSubtotalFiat" numeric(12,2) NOT NULL, "deliveryMode" "public"."checkout_session_lines_deliverymode_enum" NOT NULL, "sessionId" uuid, CONSTRAINT "PK_ca6db5cbf05b43e759bc4593ca2" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "checkout_sessions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "cancelledAt" TIMESTAMP WITH TIME ZONE, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "invoiceId" uuid, CONSTRAINT "UQ_checkout_sessions_invoice_id" UNIQUE ("invoiceId"), CONSTRAINT "PK_5730b2bbc190203a94941d82bd1" PRIMARY KEY ("id"))`
|
||||
);
|
||||
|
||||
// --- foreign keys ---
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "invoice_payments" ADD CONSTRAINT "FK_3b2a25d4269ebe9d7ca0c1001d4" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "invoice_monero_details" ADD CONSTRAINT "FK_48fcc6226a94ae8cebc91dd9864" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "orders" ADD CONSTRAINT "FK_ca23270a0d1bfb35b8d8c42346e" FOREIGN KEY ("checkoutInvoiceId") REFERENCES "invoices"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "orders" ADD CONSTRAINT "FK_f4061f63babeff4a3a48cad4708" FOREIGN KEY ("shippingInvoiceId") REFERENCES "invoices"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "order_lines" ADD CONSTRAINT "FK_307e8091afc1a959953d06d5ad1" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "order_discounts" ADD CONSTRAINT "FK_f2655f79325cfb8a9711968f65d" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "order_line_auto_fulfillment_items" ADD CONSTRAINT "FK_e9919aa158c30955c372dfce284" FOREIGN KEY ("orderLineId") REFERENCES "order_lines"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "order_line_auto_fulfillment_item_attachments" ADD CONSTRAINT "FK_2f4f82681c575092f1d11dcb222" FOREIGN KEY ("itemId") REFERENCES "order_line_auto_fulfillment_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "order_line_manual_fulfillments" ADD CONSTRAINT "FK_f34278a339a91c0663fb31fd66f" FOREIGN KEY ("orderLineId") REFERENCES "order_lines"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "order_messages" ADD CONSTRAINT "FK_cc8ace314be4d8e541ca2e6b730" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checkout_session_discounts" ADD CONSTRAINT "FK_5e4f76deb4b4325876c6bbcfe75" FOREIGN KEY ("sessionId") REFERENCES "checkout_sessions"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checkout_session_lines" ADD CONSTRAINT "FK_4a85a0cc8356f6b2a7e5802a373" FOREIGN KEY ("sessionId") REFERENCES "checkout_sessions"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checkout_sessions" ADD CONSTRAINT "FK_d795185057be72c7d4053175ba4" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "orders" ADD CONSTRAINT "FK_a50ab800c2c16d9ca50e9cda29e" FOREIGN KEY ("checkoutSessionId") REFERENCES "checkout_sessions"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "orders" DROP CONSTRAINT "FK_a50ab800c2c16d9ca50e9cda29e"`);
|
||||
await queryRunner.query(`ALTER TABLE "checkout_sessions" DROP CONSTRAINT "FK_d795185057be72c7d4053175ba4"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checkout_session_lines" DROP CONSTRAINT "FK_4a85a0cc8356f6b2a7e5802a373"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checkout_session_discounts" DROP CONSTRAINT "FK_5e4f76deb4b4325876c6bbcfe75"`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "order_messages" DROP CONSTRAINT "FK_cc8ace314be4d8e541ca2e6b730"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "order_line_manual_fulfillments" DROP CONSTRAINT "FK_f34278a339a91c0663fb31fd66f"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "order_line_auto_fulfillment_item_attachments" DROP CONSTRAINT "FK_2f4f82681c575092f1d11dcb222"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "order_line_auto_fulfillment_items" DROP CONSTRAINT "FK_e9919aa158c30955c372dfce284"`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "order_discounts" DROP CONSTRAINT "FK_f2655f79325cfb8a9711968f65d"`);
|
||||
await queryRunner.query(`ALTER TABLE "order_lines" DROP CONSTRAINT "FK_307e8091afc1a959953d06d5ad1"`);
|
||||
await queryRunner.query(`ALTER TABLE "orders" DROP CONSTRAINT "FK_f4061f63babeff4a3a48cad4708"`);
|
||||
await queryRunner.query(`ALTER TABLE "orders" DROP CONSTRAINT "FK_ca23270a0d1bfb35b8d8c42346e"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "invoice_monero_details" DROP CONSTRAINT "FK_48fcc6226a94ae8cebc91dd9864"`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "invoice_payments" DROP CONSTRAINT "FK_3b2a25d4269ebe9d7ca0c1001d4"`);
|
||||
|
||||
await queryRunner.query(`DROP TABLE "checkout_sessions"`);
|
||||
await queryRunner.query(`DROP TABLE "checkout_session_lines"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."checkout_session_lines_deliverymode_enum"`);
|
||||
await queryRunner.query(`DROP TABLE "checkout_session_discounts"`);
|
||||
|
||||
await queryRunner.query(`DROP TABLE "order_messages"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."order_messages_sender_enum"`);
|
||||
await queryRunner.query(`DROP TABLE "order_line_manual_fulfillments"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."order_line_manual_fulfillments_status_enum"`);
|
||||
await queryRunner.query(`DROP TABLE "order_line_auto_fulfillment_item_attachments"`);
|
||||
await queryRunner.query(`DROP TABLE "order_line_auto_fulfillment_items"`);
|
||||
await queryRunner.query(`DROP TABLE "order_discounts"`);
|
||||
await queryRunner.query(`DROP TABLE "order_lines"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."order_lines_deliverymode_enum"`);
|
||||
await queryRunner.query(`DROP TABLE "orders"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."orders_failurereason_enum"`);
|
||||
|
||||
await queryRunner.query(`DROP TABLE "invoice_monero_details"`);
|
||||
await queryRunner.query(`DROP TABLE "invoice_payments"`);
|
||||
await queryRunner.query(`DROP TABLE "invoices"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."invoices_paymentmethod_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."invoices_reason_enum"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddOrderStaffChatLastReadAt1784500000000 implements MigrationInterface {
|
||||
name = 'AddOrderStaffChatLastReadAt1784500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "orders" ADD "staffChatLastReadAt" TIMESTAMP WITH TIME ZONE`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "orders" DROP COLUMN "staffChatLastReadAt"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddShopNotificationSettings1784600000000 implements MigrationInterface {
|
||||
name = 'AddShopNotificationSettings1784600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "shop_settings" ADD "notificationsEnabled" boolean NOT NULL DEFAULT false`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" ADD "notifyOnNewOrder" boolean NOT NULL DEFAULT true`);
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" ADD "notifyOnOrderMessage" boolean NOT NULL DEFAULT true`);
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" ADD "simplexNotificationContactId" integer`);
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" ADD "simplexNotificationLink" varchar`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "simplexNotificationLink"`);
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "simplexNotificationContactId"`);
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "notifyOnOrderMessage"`);
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "notifyOnNewOrder"`);
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "notificationsEnabled"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class InitializeShopSettings1784600000001 implements MigrationInterface {
|
||||
name = 'InitializeShopSettings1784600000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "shop_settings" ("id")
|
||||
SELECT uuid_generate_v4()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "shop_settings")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddFaviconStorageKey1784700000000 implements MigrationInterface {
|
||||
name = 'AddFaviconStorageKey1784700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" ADD "faviconStorageKey" character varying`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "faviconStorageKey"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import dataSource from '../DataSource';
|
||||
import getConfig from '../../config';
|
||||
import { getErrorMessage } from '../../utils/getErrorMessage';
|
||||
import type { InformationSchemaTableRow } from '../../types/database/InformationSchemaTableRow';
|
||||
import type { PgEnumTypeRow } from '../../types/database/PgEnumTypeRow';
|
||||
import { NodeEnv } from '../../types/NodeEnv';
|
||||
|
||||
const {
|
||||
app: { nodeEnv }
|
||||
} = getConfig();
|
||||
|
||||
void (async () => {
|
||||
if (nodeEnv === NodeEnv.Development) {
|
||||
console.info('Connecting to the database...');
|
||||
|
||||
const initializedDataSource = await dataSource.initialize();
|
||||
|
||||
console.info('Connected to the database.');
|
||||
|
||||
const tables = await initializedDataSource.query<InformationSchemaTableRow[]>(
|
||||
`SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public';`
|
||||
);
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
await initializedDataSource.query(`DROP TABLE IF EXISTS "${table.table_name}" CASCADE;`);
|
||||
|
||||
console.info(`Dropped table: ${table.table_name}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to drop table: ${table.table_name}: ${getErrorMessage(error)}`);
|
||||
|
||||
return process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const enums = await initializedDataSource.query<PgEnumTypeRow[]>(
|
||||
`SELECT typname FROM pg_type WHERE typtype = 'e';`
|
||||
);
|
||||
|
||||
for (const enumType of enums) {
|
||||
try {
|
||||
await initializedDataSource.query(`DROP TYPE IF EXISTS ${enumType.typname} CASCADE;`);
|
||||
|
||||
console.info(`Dropped enum: ${enumType.typname}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to drop enum: ${enumType.typname}: ${getErrorMessage(error)}`);
|
||||
|
||||
return process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.info('All tables and enums have been dropped.');
|
||||
|
||||
return process.exit(0);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request } from 'express';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { Config } from '../types/Config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtGuard implements CanActivate {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
const { secret } = this.configService.get('jwt') as Config['jwt'];
|
||||
|
||||
const tokenPrefix = 'Bearer ';
|
||||
const raw: unknown = request.cookies.bearer_token;
|
||||
|
||||
if (typeof raw !== 'string' || !raw.startsWith(tokenPrefix)) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const token = raw.slice(tokenPrefix.length).trim();
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
try {
|
||||
jwt.verify(token, secret);
|
||||
} catch {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import hbs from 'hbs';
|
||||
import path from 'node:path';
|
||||
import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths';
|
||||
import { AppModule } from './AppModule';
|
||||
import { registerStorefrontHelpers } from './modules/storefrontCore/utils/registerStorefrontHelpers';
|
||||
import { registerStorefrontPartials } from './modules/storefrontCore/utils/registerStorefrontPartials';
|
||||
import { shopSurfaceHeaderMiddleware } from './middleware/shopSurfaceHeaderMiddleware';
|
||||
import { Config } from './types/Config';
|
||||
import { NodeEnv } from './types/NodeEnv';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
const { port, corsOrigins, nodeEnv } = configService.get('app') as Config['app'];
|
||||
|
||||
app.useStaticAssets(getPublicUploadsDir(), {
|
||||
prefix: publicUploadsUrlPrefix
|
||||
});
|
||||
|
||||
const storefrontPublicDir = path.join(__dirname, 'modules', 'storefrontCore', 'public');
|
||||
|
||||
app.useStaticAssets(storefrontPublicDir, {
|
||||
prefix: '/shop/assets'
|
||||
});
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
if (nodeEnv === NodeEnv.Production) {
|
||||
app.use(shopSurfaceHeaderMiddleware);
|
||||
}
|
||||
|
||||
const storefrontViewsDir = path.join(__dirname, 'modules', 'storefrontCore', 'views');
|
||||
|
||||
registerStorefrontHelpers(hbs);
|
||||
registerStorefrontPartials(hbs, path.join(storefrontViewsDir, 'partials'));
|
||||
|
||||
app.setBaseViewsDir(storefrontViewsDir);
|
||||
app.setViewEngine('hbs');
|
||||
|
||||
app.set('view options', { layout: 'layouts/shop' });
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
transformOptions: { enableImplicitConversion: true }
|
||||
})
|
||||
);
|
||||
|
||||
app.setGlobalPrefix('api', {
|
||||
exclude: [
|
||||
{ path: '/', method: RequestMethod.GET },
|
||||
{ path: 'shop/categories/:id', method: RequestMethod.GET },
|
||||
{ path: 'shop/products/:id/variants/:variantId', method: RequestMethod.GET },
|
||||
{ path: 'shop/assets/(.*)', method: RequestMethod.GET },
|
||||
{ path: 'shop/preferences/theme', method: RequestMethod.POST },
|
||||
{ path: 'shop/error', method: RequestMethod.GET },
|
||||
{ path: 'shop/cart', method: RequestMethod.GET },
|
||||
{ path: 'shop/cart/product', method: RequestMethod.POST },
|
||||
{ path: 'shop/cart/product/update', method: RequestMethod.POST },
|
||||
{ path: 'shop/cart/product/remove', method: RequestMethod.POST },
|
||||
{ path: 'shop/cart/discount', method: RequestMethod.POST },
|
||||
{ path: 'shop/cart/discount/remove', method: RequestMethod.POST },
|
||||
{ path: 'shop/checkout/pay', method: RequestMethod.POST },
|
||||
{ path: 'shop/checkout', method: RequestMethod.GET },
|
||||
{ path: 'shop/checkout/cancel', method: RequestMethod.POST },
|
||||
{ path: 'shop/check-order', method: RequestMethod.GET },
|
||||
{ path: 'shop/check-order', method: RequestMethod.POST },
|
||||
{ path: 'shop/order/:orderId', method: RequestMethod.GET },
|
||||
{ path: 'shop/order/:orderId/refresh', method: RequestMethod.GET },
|
||||
{ path: 'shop/order/:orderId/confirm-access-token-saved', method: RequestMethod.POST },
|
||||
{ path: 'shop/order/:orderId/logout', method: RequestMethod.POST },
|
||||
{ path: 'shop/order/:orderId/messages', method: RequestMethod.POST },
|
||||
{ path: 'shop/order/:orderId/messages/:messageId/delete', method: RequestMethod.POST },
|
||||
{
|
||||
path: 'shop/order/:orderId/attachments/:attachmentId/download',
|
||||
method: RequestMethod.GET
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
credentials: true
|
||||
});
|
||||
|
||||
await app.listen(port);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { SHOP_SURFACE_HEADER_NAME } from '../consts/shopSurfaceHeader';
|
||||
import { ShopSurface } from '../types/ShopSurface';
|
||||
|
||||
const isHealthCheckPath = (path: string): boolean => path === '/api/health-check' || path === '/api/health-check/';
|
||||
|
||||
export const shopSurfaceHeaderMiddleware = (req: Request, _res: Response, next: NextFunction): void => {
|
||||
if (isHealthCheckPath(req.path)) {
|
||||
next();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = req.headers[SHOP_SURFACE_HEADER_NAME];
|
||||
|
||||
if (!raw || (raw !== ShopSurface.Clearnet && raw !== ShopSurface.Onion)) {
|
||||
next(
|
||||
new BadRequestException(
|
||||
`Missing or invalid ${SHOP_SURFACE_HEADER_NAME} header; expected ${ShopSurface.Clearnet} or ${ShopSurface.Onion}`
|
||||
)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './controllers/AuthController';
|
||||
import { AuthService } from './services/AuthService';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService]
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Body, Controller, HttpStatus, Post, Req, Res } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
import { LoginDto } from '../dto/LoginDto';
|
||||
import { AuthService } from '../services/AuthService';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Config } from '../../../types/Config';
|
||||
import { throttleProfiles } from '../../../config/throttleProfiles';
|
||||
import { shouldUseSecureCookie } from '../../../utils/shouldUseSecureCookie';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
@Post('login')
|
||||
@Throttle(throttleProfiles.cmsLogin)
|
||||
login(@Body() { password }: LoginDto, @Res() res: Response, @Req() req: Request) {
|
||||
const { bearerCookie, cookieExpires } = this.authService.login(password);
|
||||
|
||||
const { nodeEnv } = this.configService.get('app') as Config['app'];
|
||||
|
||||
res.cookie('bearer_token', bearerCookie, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: shouldUseSecureCookie(nodeEnv, req),
|
||||
expires: cookieExpires
|
||||
});
|
||||
|
||||
return res.sendStatus(HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './AuthService';
|
||||
|
||||
jest.mock('jsonwebtoken', () => ({
|
||||
sign: jest.fn(() => 'signed-token')
|
||||
}));
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let configService: {
|
||||
get: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
configService = {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'app') {
|
||||
return { cmsPassword: 'secret-password' };
|
||||
}
|
||||
|
||||
if (key === 'jwt') {
|
||||
return { secret: 'jwt-secret', expiresInMs: 3_600_000 };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})
|
||||
};
|
||||
|
||||
service = new AuthService(configService as unknown as ConfigService);
|
||||
});
|
||||
|
||||
it('rejects invalid passwords', () => {
|
||||
expect(() => service.verifyPassword('wrong')).toThrow(new UnauthorizedException('Invalid credentials'));
|
||||
});
|
||||
|
||||
it('accepts the configured cms password', () => {
|
||||
expect(() => service.verifyPassword('secret-password')).not.toThrow();
|
||||
});
|
||||
|
||||
it('returns a bearer cookie and expiry when login succeeds', () => {
|
||||
const result = service.login('secret-password');
|
||||
|
||||
expect(result.bearerCookie).toBe('Bearer signed-token');
|
||||
expect(result.cookieExpires).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import dayjs from '../../../plugins/dayjs';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { Config } from '../../../types/Config';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
verifyPassword(password: string): void {
|
||||
const { cmsPassword } = this.configService.get('app') as Config['app'];
|
||||
|
||||
if (password !== cmsPassword) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
}
|
||||
|
||||
login(password: string): { bearerCookie: string; cookieExpires: Date } {
|
||||
this.verifyPassword(password);
|
||||
|
||||
const { secret, expiresInMs } = this.configService.get('jwt') as Config['jwt'];
|
||||
|
||||
const token = jwt.sign({}, secret, { expiresIn: Math.floor(expiresInMs / 1000) });
|
||||
|
||||
return {
|
||||
bearerCookie: `Bearer ${token}`,
|
||||
cookieExpires: dayjs().add(expiresInMs, 'millisecond').toDate()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OrderModule } from '../order/OrderModule';
|
||||
import { PaymentModule } from '../payment/PaymentModule';
|
||||
import { StorefrontCheckoutModule } from '../storefrontCheckout/StorefrontCheckoutModule';
|
||||
import { OrderDataWipeService } from './services/OrderDataWipeService';
|
||||
|
||||
@Module({
|
||||
imports: [OrderModule, PaymentModule, StorefrontCheckoutModule],
|
||||
providers: [OrderDataWipeService]
|
||||
})
|
||||
export class DataWipeModule {}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import { Order } from '../../order/entities/Order';
|
||||
import { Invoice } from '../../payment/entities/Invoice';
|
||||
import { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession';
|
||||
import { OrderDataWipeService } from './OrderDataWipeService';
|
||||
|
||||
describe('OrderDataWipeService', () => {
|
||||
let orderRepo: {
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let dataSource: {
|
||||
transaction: jest.Mock;
|
||||
};
|
||||
let configService: {
|
||||
get: jest.Mock;
|
||||
};
|
||||
let service: OrderDataWipeService;
|
||||
let queryBuilder: {
|
||||
select: jest.Mock;
|
||||
addSelect: jest.Mock;
|
||||
where: jest.Mock;
|
||||
andWhere: jest.Mock;
|
||||
orderBy: jest.Mock;
|
||||
limit: jest.Mock;
|
||||
getRawMany: jest.Mock;
|
||||
};
|
||||
let transactionManager: {
|
||||
getRepository: jest.Mock;
|
||||
};
|
||||
let transactionalOrderRepo: { delete: jest.Mock };
|
||||
let transactionalSessionRepo: { delete: jest.Mock };
|
||||
let transactionalInvoiceRepo: { delete: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
queryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn()
|
||||
};
|
||||
|
||||
orderRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(queryBuilder)
|
||||
};
|
||||
|
||||
transactionalOrderRepo = { delete: jest.fn().mockResolvedValue(undefined) };
|
||||
transactionalSessionRepo = { delete: jest.fn().mockResolvedValue(undefined) };
|
||||
transactionalInvoiceRepo = { delete: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
transactionManager = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Order) {
|
||||
return transactionalOrderRepo;
|
||||
}
|
||||
|
||||
if (entity === CheckoutSession) {
|
||||
return transactionalSessionRepo;
|
||||
}
|
||||
|
||||
if (entity === Invoice) {
|
||||
return transactionalInvoiceRepo;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected entity: ${String(entity)}`);
|
||||
})
|
||||
};
|
||||
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (callback: (manager: EntityManager) => Promise<void>) => {
|
||||
await callback(transactionManager as unknown as EntityManager);
|
||||
})
|
||||
};
|
||||
|
||||
configService = {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'order') {
|
||||
return { dataRetentionDays: 30 };
|
||||
}
|
||||
|
||||
return {};
|
||||
})
|
||||
};
|
||||
|
||||
service = new OrderDataWipeService(
|
||||
orderRepo as unknown as Repository<Order>,
|
||||
dataSource as unknown as DataSource,
|
||||
configService as unknown as ConfigService
|
||||
);
|
||||
});
|
||||
|
||||
it('computes retention cutoff from configured days', () => {
|
||||
const before = Date.now();
|
||||
|
||||
const cutoff = service.getRetentionCutoff();
|
||||
|
||||
const after = Date.now();
|
||||
const expectedMs = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
expect(cutoff.getTime()).toBeGreaterThanOrEqual(before - expectedMs - 1000);
|
||||
expect(cutoff.getTime()).toBeLessThanOrEqual(after - expectedMs + 1000);
|
||||
});
|
||||
|
||||
it('finds expired orders with linked session and invoice ids', async () => {
|
||||
queryBuilder.getRawMany.mockResolvedValue([
|
||||
{
|
||||
id: 'order-1',
|
||||
checkoutSessionId: 'session-1',
|
||||
checkoutInvoiceId: 'invoice-1',
|
||||
shippingInvoiceId: 'invoice-2'
|
||||
}
|
||||
]);
|
||||
|
||||
const cutoff = new Date('2026-01-01T00:00:00.000Z');
|
||||
jest.spyOn(service, 'getRetentionCutoff').mockReturnValue(cutoff);
|
||||
|
||||
const targets = await service.findExpiredOrderWipeTargets();
|
||||
|
||||
expect(queryBuilder.where).toHaveBeenCalledWith('order.createdAt < :cutoff', { cutoff });
|
||||
expect(queryBuilder.andWhere).toHaveBeenCalledWith('order.checkoutSessionId IS NOT NULL');
|
||||
expect(queryBuilder.andWhere).toHaveBeenCalledWith('order.checkoutInvoiceId IS NOT NULL');
|
||||
expect(queryBuilder.limit).toHaveBeenCalledWith(50);
|
||||
expect(targets).toEqual([
|
||||
{
|
||||
id: 'order-1',
|
||||
checkoutSessionId: 'session-1',
|
||||
checkoutInvoiceId: 'invoice-1',
|
||||
shippingInvoiceId: 'invoice-2'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('deletes order, checkout session, and invoices in one transaction', async () => {
|
||||
await service.wipeOrder({
|
||||
id: 'order-1',
|
||||
checkoutSessionId: 'session-1',
|
||||
checkoutInvoiceId: 'invoice-1',
|
||||
shippingInvoiceId: 'invoice-2'
|
||||
});
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(transactionalOrderRepo.delete).toHaveBeenCalledWith('order-1');
|
||||
expect(transactionalSessionRepo.delete).toHaveBeenCalledWith('session-1');
|
||||
expect(transactionalInvoiceRepo.delete).toHaveBeenCalledWith(['invoice-1', 'invoice-2']);
|
||||
});
|
||||
|
||||
it('skips shipping invoice delete when absent', async () => {
|
||||
await service.wipeOrder({
|
||||
id: 'order-1',
|
||||
checkoutSessionId: 'session-1',
|
||||
checkoutInvoiceId: 'invoice-1',
|
||||
shippingInvoiceId: null
|
||||
});
|
||||
|
||||
expect(transactionalInvoiceRepo.delete).toHaveBeenCalledWith(['invoice-1']);
|
||||
});
|
||||
|
||||
it('stops after max iterations when orders keep failing to delete', async () => {
|
||||
const target = {
|
||||
id: 'order-1',
|
||||
checkoutSessionId: 'session-1',
|
||||
checkoutInvoiceId: 'invoice-1',
|
||||
shippingInvoiceId: null
|
||||
};
|
||||
|
||||
jest.spyOn(service, 'findExpiredOrderWipeTargets').mockResolvedValue([target]);
|
||||
jest.spyOn(service, 'wipeOrder').mockRejectedValue(new Error('delete failed'));
|
||||
const warnSpy = jest.spyOn(service['logger'], 'warn').mockImplementation();
|
||||
jest.spyOn(service['logger'], 'error').mockImplementation();
|
||||
|
||||
await service.wipeExpiredOrders();
|
||||
|
||||
expect(service.wipeOrder).toHaveBeenCalledTimes(20);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Order wipe max iterations limit reached. Most likely some order keeps failing to be deleted or there are huge amount of orders to wipe.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import dayjs from '../../../plugins/dayjs';
|
||||
import type { Config } from '../../../types/Config';
|
||||
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||
import { Order } from '../../order/entities/Order';
|
||||
import { Invoice } from '../../payment/entities/Invoice';
|
||||
import { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession';
|
||||
import type { OrderWipeTarget } from '../types/OrderWipeTarget';
|
||||
|
||||
@Injectable()
|
||||
export class OrderDataWipeService {
|
||||
private readonly logger = new Logger(OrderDataWipeService.name);
|
||||
|
||||
private readonly wipeBatchSize = 50;
|
||||
private readonly maxWipeIterations = 20;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Order)
|
||||
private readonly orderRepo: Repository<Order>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async wipeExpiredOrders(): Promise<void> {
|
||||
let wipedCount = 0;
|
||||
let iterations = 0;
|
||||
|
||||
let targets = await this.findExpiredOrderWipeTargets();
|
||||
|
||||
while (targets.length > 0 && iterations < this.maxWipeIterations) {
|
||||
for (const target of targets) {
|
||||
try {
|
||||
await this.wipeOrder(target);
|
||||
|
||||
wipedCount += 1;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to wipe order ${target.id}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
targets = await this.findExpiredOrderWipeTargets();
|
||||
iterations += 1;
|
||||
}
|
||||
|
||||
if (iterations >= this.maxWipeIterations && targets.length > 0) {
|
||||
this.logger.warn(
|
||||
'Order wipe max iterations limit reached. Most likely some order keeps failing to be deleted or there are huge amount of orders to wipe.'
|
||||
);
|
||||
}
|
||||
|
||||
if (wipedCount > 0) {
|
||||
this.logger.log(`Wiped ${wipedCount} expired order(s).`);
|
||||
}
|
||||
}
|
||||
|
||||
async findExpiredOrderWipeTargets(): Promise<OrderWipeTarget[]> {
|
||||
const cutoff = this.getRetentionCutoff();
|
||||
|
||||
return this.orderRepo
|
||||
.createQueryBuilder('order')
|
||||
.select('order.id', 'id')
|
||||
.addSelect('order.checkoutSessionId', 'checkoutSessionId')
|
||||
.addSelect('order.checkoutInvoiceId', 'checkoutInvoiceId')
|
||||
.addSelect('order.shippingInvoiceId', 'shippingInvoiceId')
|
||||
.where('order.createdAt < :cutoff', { cutoff })
|
||||
.andWhere('order.checkoutSessionId IS NOT NULL')
|
||||
.andWhere('order.checkoutInvoiceId IS NOT NULL')
|
||||
.orderBy('order.createdAt', 'ASC')
|
||||
.limit(this.wipeBatchSize)
|
||||
.getRawMany<OrderWipeTarget>();
|
||||
}
|
||||
|
||||
async wipeOrder({ id, checkoutSessionId, checkoutInvoiceId, shippingInvoiceId }: OrderWipeTarget): Promise<void> {
|
||||
const invoiceIds = [checkoutInvoiceId, shippingInvoiceId].filter((invoiceId): invoiceId is string =>
|
||||
Boolean(invoiceId)
|
||||
);
|
||||
|
||||
await this.dataSource.transaction(async manager => {
|
||||
const orderRepo = manager.getRepository(Order);
|
||||
const sessionRepo = manager.getRepository(CheckoutSession);
|
||||
const invoiceRepo = manager.getRepository(Invoice);
|
||||
|
||||
await orderRepo.delete(id);
|
||||
await sessionRepo.delete(checkoutSessionId);
|
||||
await invoiceRepo.delete(invoiceIds);
|
||||
});
|
||||
}
|
||||
|
||||
getRetentionCutoff(): Date {
|
||||
const { dataRetentionDays } = this.configService.get('order') as Config['order'];
|
||||
|
||||
return dayjs().subtract(dataRetentionDays, 'day').toDate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type OrderWipeTarget = {
|
||||
id: string;
|
||||
checkoutSessionId: string;
|
||||
checkoutInvoiceId: string;
|
||||
shippingInvoiceId: string | null;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ProductsModule } from '../product/ProductsModule';
|
||||
import { DiscountCodesController } from './controllers/DiscountCodesController';
|
||||
import { DiscountCode } from './entities/DiscountCode';
|
||||
import { DiscountCodesService } from './services/DiscountCodesService';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([DiscountCode]), ProductsModule],
|
||||
controllers: [DiscountCodesController],
|
||||
providers: [DiscountCodesService],
|
||||
exports: [DiscountCodesService]
|
||||
})
|
||||
export class DiscountCodesModule {}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { JwtGuard } from '../../../guards/JwtGuard';
|
||||
import { CreateOrUpdateDiscountCodeDto } from '../dto/CreateOrUpdateDiscountCodeDto';
|
||||
import { DiscountCodesService } from '../services/DiscountCodesService';
|
||||
|
||||
@Controller('discount-codes')
|
||||
@UseGuards(JwtGuard)
|
||||
export class DiscountCodesController {
|
||||
constructor(private readonly discountCodesService: DiscountCodesService) {}
|
||||
|
||||
@Get('/')
|
||||
findAll() {
|
||||
return this.discountCodesService.findAll();
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.discountCodesService.findOne(id);
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
create(@Body() dto: CreateOrUpdateDiscountCodeDto) {
|
||||
return this.discountCodesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch('/:id')
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateOrUpdateDiscountCodeDto) {
|
||||
return this.discountCodesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.discountCodesService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
import { getAppConfig } from '../../../config';
|
||||
import { NullOrDate, NullOrInt, NullOrNumber } from '../../../validation/decorators/nullOr';
|
||||
import { DiscountType } from '../types/DiscountType';
|
||||
|
||||
const {
|
||||
validation: { discountCodeMaxLength }
|
||||
} = getAppConfig();
|
||||
|
||||
export class CreateOrUpdateDiscountCodeDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(discountCodeMaxLength)
|
||||
code: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsEnum(DiscountType)
|
||||
type: DiscountType;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
value: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsBoolean()
|
||||
isActive: boolean;
|
||||
|
||||
@NullOrDate()
|
||||
validFrom: Date | null;
|
||||
|
||||
@NullOrDate()
|
||||
validUntil: Date | null;
|
||||
|
||||
@NullOrInt({ min: 1 })
|
||||
maxRedemptions: number | null;
|
||||
|
||||
@NullOrNumber({ min: 0 })
|
||||
minOrderAmount: number | null;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsBoolean()
|
||||
isExclusive: boolean;
|
||||
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
productIds: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
categoryIds: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
variantIds: string[];
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn
|
||||
} from 'typeorm';
|
||||
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
|
||||
import { Category } from '../../product/entities/Category';
|
||||
import { Product } from '../../product/entities/Product';
|
||||
import { ProductVariant } from '../../product/entities/ProductVariant';
|
||||
import { DiscountType } from '../types/DiscountType';
|
||||
|
||||
@Entity('discount_codes')
|
||||
export class DiscountCode {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'enum', enum: DiscountType })
|
||||
type: DiscountType;
|
||||
|
||||
@Column({
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
transformer: new ColumnNumericTransformer()
|
||||
})
|
||||
value: number;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true, default: null })
|
||||
validFrom: Date | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true, default: null })
|
||||
validUntil: Date | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true, default: null })
|
||||
maxRedemptions: number | null;
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
redemptionCount: number;
|
||||
|
||||
@Column({
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
default: null,
|
||||
transformer: new ColumnNumericTransformer()
|
||||
})
|
||||
minOrderAmount: number | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isExclusive: boolean;
|
||||
|
||||
@ManyToMany(() => Product)
|
||||
@JoinTable({
|
||||
name: 'discount_codes_products',
|
||||
joinColumn: { name: 'discountCodeId', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'productId', referencedColumnName: 'id' }
|
||||
})
|
||||
products: Product[];
|
||||
|
||||
@ManyToMany(() => Category)
|
||||
@JoinTable({
|
||||
name: 'discount_codes_categories',
|
||||
joinColumn: { name: 'discountCodeId', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'categoryId', referencedColumnName: 'id' }
|
||||
})
|
||||
categories: Category[];
|
||||
|
||||
@ManyToMany(() => ProductVariant)
|
||||
@JoinTable({
|
||||
name: 'discount_codes_variants',
|
||||
joinColumn: { name: 'discountCodeId', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'variantId', referencedColumnName: 'id' }
|
||||
})
|
||||
variants: ProductVariant[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import type { Repository } from 'typeorm';
|
||||
import type { CategoriesService } from '../../product/services/CategoriesService';
|
||||
import type { ProductVariantsService } from '../../product/services/ProductVariantsService';
|
||||
import type { ProductsService } from '../../product/services/ProductsService';
|
||||
import { DiscountCode } from '../entities/DiscountCode';
|
||||
import { DiscountType } from '../types/DiscountType';
|
||||
import { DiscountCodesService } from './DiscountCodesService';
|
||||
|
||||
const buildDto = () => ({
|
||||
code: ' save10 ',
|
||||
type: DiscountType.Percent,
|
||||
value: 10,
|
||||
isActive: true,
|
||||
validFrom: null,
|
||||
validUntil: null,
|
||||
maxRedemptions: null,
|
||||
minOrderAmount: null,
|
||||
isExclusive: false,
|
||||
productIds: [] as string[],
|
||||
categoryIds: [] as string[],
|
||||
variantIds: [] as string[]
|
||||
});
|
||||
|
||||
describe('DiscountCodesService', () => {
|
||||
let service: DiscountCodesService;
|
||||
let discountCodeRepo: {
|
||||
find: jest.Mock;
|
||||
findOne: jest.Mock;
|
||||
create: jest.Mock;
|
||||
save: jest.Mock;
|
||||
exists: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
};
|
||||
let productsService: {
|
||||
findByIds: jest.Mock;
|
||||
};
|
||||
let categoriesService: {
|
||||
findByIds: jest.Mock;
|
||||
};
|
||||
let productVariantsService: {
|
||||
findByIds: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
discountCodeRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn(data => ({ id: 'discount-1', ...data })),
|
||||
save: jest.fn(async (entity: DiscountCode) => entity),
|
||||
exists: jest.fn().mockResolvedValue(false),
|
||||
delete: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
|
||||
productsService = {
|
||||
findByIds: jest.fn().mockResolvedValue([])
|
||||
};
|
||||
|
||||
categoriesService = {
|
||||
findByIds: jest.fn().mockResolvedValue([])
|
||||
};
|
||||
|
||||
productVariantsService = {
|
||||
findByIds: jest.fn().mockResolvedValue([])
|
||||
};
|
||||
|
||||
service = new DiscountCodesService(
|
||||
discountCodeRepo as unknown as Repository<DiscountCode>,
|
||||
productsService as unknown as ProductsService,
|
||||
productVariantsService as unknown as ProductVariantsService,
|
||||
categoriesService as unknown as CategoriesService
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes discount codes by trimming and uppercasing', () => {
|
||||
expect(service.normalizeCode(' save10 ')).toBe('SAVE10');
|
||||
});
|
||||
|
||||
it('returns an empty list when no normalized codes are provided', async () => {
|
||||
await expect(service.findByNormalizedCodes([])).resolves.toEqual([]);
|
||||
expect(discountCodeRepo.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deduplicates normalized codes before loading entities', async () => {
|
||||
await service.findByNormalizedCodes(['save10', 'SAVE10', ' save10 ']);
|
||||
|
||||
expect(discountCodeRepo.find).toHaveBeenCalledWith({
|
||||
where: { code: expect.anything() },
|
||||
relations: ['products', 'categories', 'variants']
|
||||
});
|
||||
const whereArg = discountCodeRepo.find.mock.calls[0][0].where.code;
|
||||
expect(whereArg._value).toEqual(['SAVE10']);
|
||||
});
|
||||
|
||||
it('rejects duplicate codes on create', async () => {
|
||||
discountCodeRepo.findOne.mockResolvedValue({ id: 'existing', code: 'SAVE10' });
|
||||
|
||||
await expect(service.create(buildDto())).rejects.toThrow(
|
||||
new BadRequestException('Discount code already exists')
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects percent values outside 0-100', async () => {
|
||||
await expect(service.create({ ...buildDto(), value: 101 })).rejects.toThrow(
|
||||
new BadRequestException('Percent value must be between 0 and 100')
|
||||
);
|
||||
|
||||
await expect(service.create({ ...buildDto(), value: -1 })).rejects.toThrow(
|
||||
new BadRequestException('Percent value must be between 0 and 100')
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects negative fixed discount values', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
...buildDto(),
|
||||
type: DiscountType.Fixed,
|
||||
value: -5
|
||||
})
|
||||
).rejects.toThrow(new BadRequestException('Fixed value must be at least 0'));
|
||||
});
|
||||
|
||||
it('allows updating a code without treating itself as a duplicate', async () => {
|
||||
const existing = { id: 'discount-1', code: 'SAVE10' } as DiscountCode;
|
||||
|
||||
discountCodeRepo.findOne
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce(existing);
|
||||
|
||||
await service.update('discount-1', buildDto());
|
||||
|
||||
expect(discountCodeRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects updating to a code owned by another discount', async () => {
|
||||
discountCodeRepo.findOne
|
||||
.mockResolvedValueOnce({ id: 'discount-1', code: 'OLD' } as DiscountCode)
|
||||
.mockResolvedValueOnce({ id: 'discount-2', code: 'SAVE10' });
|
||||
|
||||
await expect(service.update('discount-1', buildDto())).rejects.toThrow(
|
||||
new BadRequestException('Discount code already exists')
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects discount date ranges where validFrom is after validUntil', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
...buildDto(),
|
||||
validFrom: new Date('2026-02-01T00:00:00.000Z'),
|
||||
validUntil: new Date('2026-01-01T00:00:00.000Z')
|
||||
})
|
||||
).rejects.toThrow(new BadRequestException('Date from should be before date until'));
|
||||
});
|
||||
|
||||
it('creates a normalized discount code', async () => {
|
||||
discountCodeRepo.findOne
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 'discount-1', code: 'SAVE10' } as DiscountCode);
|
||||
|
||||
const created = await service.create(buildDto());
|
||||
|
||||
expect(discountCodeRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: 'SAVE10',
|
||||
type: DiscountType.Percent,
|
||||
value: 10
|
||||
})
|
||||
);
|
||||
expect(discountCodeRepo.save).toHaveBeenCalled();
|
||||
expect(created).toEqual(expect.objectContaining({ id: 'discount-1', code: 'SAVE10' }));
|
||||
});
|
||||
|
||||
it('throws when loading a missing discount code by id', async () => {
|
||||
await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('deletes an existing discount code', async () => {
|
||||
discountCodeRepo.exists.mockResolvedValue(true);
|
||||
|
||||
await service.remove('discount-1');
|
||||
|
||||
expect(discountCodeRepo.delete).toHaveBeenCalledWith('discount-1');
|
||||
});
|
||||
|
||||
it('throws when deleting a missing discount code', async () => {
|
||||
discountCodeRepo.exists.mockResolvedValue(false);
|
||||
|
||||
await expect(service.remove('missing-id')).rejects.toThrow(NotFoundException);
|
||||
expect(discountCodeRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import dayjs from '../../../plugins/dayjs';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { CategoriesService } from '../../product/services/CategoriesService';
|
||||
import { ProductVariantsService } from '../../product/services/ProductVariantsService';
|
||||
import { ProductsService } from '../../product/services/ProductsService';
|
||||
import type { CreateOrUpdateDiscountCodeDto } from '../dto/CreateOrUpdateDiscountCodeDto';
|
||||
import { DiscountCode } from '../entities/DiscountCode';
|
||||
import { DiscountType } from '../types/DiscountType';
|
||||
|
||||
@Injectable()
|
||||
export class DiscountCodesService {
|
||||
constructor(
|
||||
@InjectRepository(DiscountCode)
|
||||
private readonly discountCodeRepo: Repository<DiscountCode>,
|
||||
private readonly productsService: ProductsService,
|
||||
private readonly productVariantsService: ProductVariantsService,
|
||||
private readonly categoriesService: CategoriesService
|
||||
) {}
|
||||
|
||||
normalizeCode(raw: string): string {
|
||||
return raw.trim().toUpperCase();
|
||||
}
|
||||
|
||||
async findAll(): Promise<DiscountCode[]> {
|
||||
return this.discountCodeRepo.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
relations: ['products', 'categories', 'variants', 'variants.product']
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<DiscountCode> {
|
||||
const entity = await this.discountCodeRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['products', 'categories', 'variants', 'variants.product']
|
||||
});
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
async findByNormalizedCodes(codes: string[]): Promise<DiscountCode[]> {
|
||||
if (codes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalized = [...new Set(codes.map(c => this.normalizeCode(c)))];
|
||||
|
||||
return this.discountCodeRepo.find({
|
||||
where: { code: In(normalized) },
|
||||
relations: ['products', 'categories', 'variants']
|
||||
});
|
||||
}
|
||||
|
||||
async create({
|
||||
code,
|
||||
type,
|
||||
value,
|
||||
isActive,
|
||||
validFrom,
|
||||
validUntil,
|
||||
maxRedemptions,
|
||||
minOrderAmount,
|
||||
isExclusive,
|
||||
productIds,
|
||||
categoryIds,
|
||||
variantIds
|
||||
}: CreateOrUpdateDiscountCodeDto): Promise<DiscountCode> {
|
||||
const normalizedCode = this.normalizeCode(code);
|
||||
|
||||
await this.validateCodeAvailable(normalizedCode);
|
||||
|
||||
this.validateDiscountDateRange(validFrom, validUntil);
|
||||
this.validateDiscountValueForType(type, value);
|
||||
|
||||
const [products, categories, variants] = await Promise.all([
|
||||
this.productsService.findByIds(productIds),
|
||||
this.categoriesService.findByIds(categoryIds),
|
||||
this.productVariantsService.findByIds(variantIds)
|
||||
]);
|
||||
|
||||
const entity = this.discountCodeRepo.create({
|
||||
code: normalizedCode,
|
||||
type,
|
||||
value,
|
||||
isActive,
|
||||
validFrom,
|
||||
validUntil,
|
||||
maxRedemptions,
|
||||
minOrderAmount,
|
||||
isExclusive,
|
||||
products,
|
||||
categories,
|
||||
variants
|
||||
});
|
||||
|
||||
await this.discountCodeRepo.save(entity);
|
||||
|
||||
return this.findOne(entity.id);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
{
|
||||
code,
|
||||
type,
|
||||
value,
|
||||
isActive,
|
||||
validFrom,
|
||||
validUntil,
|
||||
maxRedemptions,
|
||||
minOrderAmount,
|
||||
isExclusive,
|
||||
productIds,
|
||||
categoryIds,
|
||||
variantIds
|
||||
}: CreateOrUpdateDiscountCodeDto
|
||||
): Promise<DiscountCode> {
|
||||
const entity = await this.discountCodeRepo.findOne({ where: { id } });
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const normalizedCode = this.normalizeCode(code);
|
||||
|
||||
await this.validateCodeAvailable(normalizedCode, id);
|
||||
|
||||
this.validateDiscountDateRange(validFrom, validUntil);
|
||||
this.validateDiscountValueForType(type, value);
|
||||
|
||||
const [products, categories, variants] = await Promise.all([
|
||||
this.productsService.findByIds(productIds),
|
||||
this.categoriesService.findByIds(categoryIds),
|
||||
this.productVariantsService.findByIds(variantIds)
|
||||
]);
|
||||
|
||||
entity.code = normalizedCode;
|
||||
entity.type = type;
|
||||
entity.value = value;
|
||||
entity.isActive = isActive;
|
||||
entity.validFrom = validFrom;
|
||||
entity.validUntil = validUntil;
|
||||
entity.maxRedemptions = maxRedemptions;
|
||||
entity.minOrderAmount = minOrderAmount;
|
||||
entity.isExclusive = isExclusive;
|
||||
entity.products = products;
|
||||
entity.categories = categories;
|
||||
entity.variants = variants;
|
||||
|
||||
await this.discountCodeRepo.save(entity);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const exists = await this.discountCodeRepo.exists({ where: { id } });
|
||||
|
||||
if (!exists) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await this.discountCodeRepo.delete(id);
|
||||
}
|
||||
|
||||
private async validateCodeAvailable(code: string, excludeId?: string): Promise<void> {
|
||||
const existing = await this.discountCodeRepo.findOne({ where: { code } });
|
||||
|
||||
if (existing && existing.id !== excludeId) {
|
||||
throw new BadRequestException('Discount code already exists');
|
||||
}
|
||||
}
|
||||
|
||||
private validateDiscountDateRange(validFrom: Date | null, validUntil: Date | null): void {
|
||||
if (validFrom && validUntil && dayjs(validFrom).isAfter(dayjs(validUntil))) {
|
||||
throw new BadRequestException('Date from should be before date until');
|
||||
}
|
||||
}
|
||||
|
||||
private validateDiscountValueForType(type: DiscountType, value: number): void {
|
||||
if (type === DiscountType.Percent && (value < 0 || value > 100)) {
|
||||
throw new BadRequestException('Percent value must be between 0 and 100');
|
||||
}
|
||||
|
||||
if (type === DiscountType.Fixed && value < 0) {
|
||||
throw new BadRequestException('Fixed value must be at least 0');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum DiscountType {
|
||||
Percent = 'percent',
|
||||
Fixed = 'fixed'
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EncryptionService } from './services/EncryptionService';
|
||||
|
||||
@Module({
|
||||
providers: [EncryptionService],
|
||||
exports: [EncryptionService]
|
||||
})
|
||||
export class EncryptionModule {}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { EncryptedField } from '../types/EncryptedField';
|
||||
import { EncryptionService } from './EncryptionService';
|
||||
|
||||
describe('EncryptionService', () => {
|
||||
const validKeyBase64 = randomBytes(32).toString('base64');
|
||||
let service: EncryptionService;
|
||||
let configGet: jest.MockedFunction<ConfigService['get']>;
|
||||
|
||||
beforeEach(() => {
|
||||
configGet = jest.fn().mockReturnValue({ keyBase64: validKeyBase64 }) as jest.MockedFunction<
|
||||
ConfigService['get']
|
||||
>;
|
||||
service = new EncryptionService({ get: configGet } as unknown as ConfigService);
|
||||
});
|
||||
|
||||
const parseSerialized = (serialized: string): EncryptedField => JSON.parse(serialized) as EncryptedField;
|
||||
|
||||
it('reads encryption config when encrypting plaintext', () => {
|
||||
service.encryptPlaintext('hello');
|
||||
|
||||
expect(configGet).toHaveBeenCalledWith('encryption');
|
||||
});
|
||||
|
||||
it('round-trips plaintext', () => {
|
||||
const plaintext = 'Deliver via Simplex: example-handle';
|
||||
const serialized = service.encryptPlaintext(plaintext);
|
||||
|
||||
expect(service.decryptPlaintext(serialized)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('throws when encryption key is not configured', () => {
|
||||
configGet.mockReturnValue({ keyBase64: '' });
|
||||
|
||||
expect(() => service.encryptPlaintext('x')).toThrow('Encryption key is not configured');
|
||||
});
|
||||
|
||||
it('throws when encryption key is not 32 bytes', () => {
|
||||
configGet.mockReturnValue({ keyBase64: Buffer.from('short').toString('base64') });
|
||||
|
||||
expect(() => service.encryptPlaintext('x')).toThrow('Encryption key must be a base64-encoded 32-byte value');
|
||||
});
|
||||
|
||||
it('throws on invalid serialized payload', () => {
|
||||
expect(() => service.decryptPlaintext('{"ciphertext":"x"}')).toThrow('Invalid encrypted field payload');
|
||||
});
|
||||
|
||||
it('fails decrypt when ciphertext is tampered', () => {
|
||||
const serialized = service.encryptPlaintext('secret');
|
||||
const encrypted = parseSerialized(serialized);
|
||||
|
||||
encrypted.ciphertext = Buffer.from('tampered').toString('base64');
|
||||
|
||||
expect(() => service.decryptPlaintext(JSON.stringify(encrypted))).toThrow();
|
||||
});
|
||||
|
||||
it('throws when encryption key is not configured on decrypt', () => {
|
||||
const serialized = service.encryptPlaintext('secret');
|
||||
configGet.mockReturnValue({ keyBase64: '' });
|
||||
|
||||
expect(() => service.decryptPlaintext(serialized)).toThrow('Encryption key is not configured');
|
||||
});
|
||||
|
||||
it('fails decrypt when auth tag is tampered', () => {
|
||||
const serialized = service.encryptPlaintext('secret');
|
||||
const encrypted = parseSerialized(serialized);
|
||||
|
||||
encrypted.tag = Buffer.from('tampered-tag').toString('base64');
|
||||
|
||||
expect(() => service.decryptPlaintext(JSON.stringify(encrypted))).toThrow();
|
||||
});
|
||||
|
||||
it('fails decrypt when encryption key changes', () => {
|
||||
const serialized = service.encryptPlaintext('secret');
|
||||
configGet.mockReturnValue({ keyBase64: randomBytes(32).toString('base64') });
|
||||
|
||||
expect(() => service.decryptPlaintext(serialized)).toThrow();
|
||||
});
|
||||
|
||||
it('decryptPlaintextFieldInPlace decrypts each item field', () => {
|
||||
const messages = [{ body: service.encryptPlaintext('Hello') }, { body: service.encryptPlaintext('World') }];
|
||||
|
||||
service.decryptPlaintextFieldInPlace(messages, 'body');
|
||||
|
||||
expect(messages).toEqual([{ body: 'Hello' }, { body: 'World' }]);
|
||||
});
|
||||
|
||||
it('decryptPlaintextFieldInPlace handles undefined items', () => {
|
||||
expect(() => service.decryptPlaintextFieldInPlace(undefined, 'body')).not.toThrow();
|
||||
});
|
||||
|
||||
it('round-trips files written as encrypted buffer', async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'encryption-service-'));
|
||||
const filePath = join(tempDir, 'attachment.bin');
|
||||
const plaintext = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d, 0x00, 0xff]);
|
||||
|
||||
try {
|
||||
await service.writeEncryptedBufferToPath(plaintext, filePath);
|
||||
|
||||
const onDisk = await readFile(filePath, 'utf8');
|
||||
|
||||
expect(onDisk).not.toEqual(plaintext.toString());
|
||||
|
||||
expect(await service.decryptFileAtPath(filePath)).toEqual(plaintext);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { Config } from '../../../types/Config';
|
||||
import { EncryptedField } from '../types/EncryptedField';
|
||||
|
||||
@Injectable()
|
||||
export class EncryptionService {
|
||||
private readonly algorithm = 'aes-256-gcm';
|
||||
private readonly ivLengthBytes = 12;
|
||||
private readonly encryptionKeyByteLength = 32;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
encryptPlaintext(plaintext: string): string {
|
||||
const encrypted = this.encryptBuffer(Buffer.from(plaintext, 'utf8'));
|
||||
|
||||
return this.serialize(encrypted);
|
||||
}
|
||||
|
||||
decryptPlaintext(serialized: string): string {
|
||||
const encrypted = this.deserialize(serialized);
|
||||
|
||||
return this.decryptBuffer(encrypted).toString('utf8');
|
||||
}
|
||||
|
||||
decryptPlaintextFieldInPlace<T>(items: T[] | undefined, field: keyof T): void {
|
||||
this.decryptPlaintextInPlace(
|
||||
items,
|
||||
item => item[field] as string,
|
||||
(item, plaintext) => {
|
||||
(item as Record<keyof T, unknown>)[field] = plaintext;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async writeEncryptedBufferToPath(plaintext: Buffer, absolutePath: string): Promise<void> {
|
||||
const serialized = this.encryptBufferToSerialized(plaintext);
|
||||
|
||||
await writeFile(absolutePath, serialized, 'utf8');
|
||||
}
|
||||
|
||||
async decryptFileAtPath(absolutePath: string): Promise<Buffer> {
|
||||
const serialized = await readFile(absolutePath, 'utf8');
|
||||
|
||||
return this.decryptBufferToSerialized(serialized);
|
||||
}
|
||||
|
||||
private decryptPlaintextInPlace<T>(
|
||||
items: T[] | undefined,
|
||||
read: (item: T) => string,
|
||||
write: (item: T, plaintext: string) => void
|
||||
): void {
|
||||
if (!items) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
write(item, this.decryptPlaintext(read(item)));
|
||||
}
|
||||
}
|
||||
|
||||
private encryptBufferToSerialized(plaintext: Buffer): string {
|
||||
const encrypted = this.encryptBuffer(plaintext);
|
||||
|
||||
return this.serialize(encrypted);
|
||||
}
|
||||
|
||||
private decryptBufferToSerialized(serialized: string): Buffer {
|
||||
const encrypted = this.deserialize(serialized);
|
||||
|
||||
return this.decryptBuffer(encrypted);
|
||||
}
|
||||
|
||||
private encryptBuffer(plaintext: Buffer): EncryptedField {
|
||||
const key = this.parseKey();
|
||||
const iv = randomBytes(this.ivLengthBytes);
|
||||
const cipher = createCipheriv(this.algorithm, key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
ciphertext: ciphertext.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
tag: tag.toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
private decryptBuffer(payload: EncryptedField): Buffer {
|
||||
const key = this.parseKey();
|
||||
const iv = Buffer.from(payload.iv, 'base64');
|
||||
const tag = Buffer.from(payload.tag, 'base64');
|
||||
const ciphertext = Buffer.from(payload.ciphertext, 'base64');
|
||||
const decipher = createDecipheriv(this.algorithm, key, iv);
|
||||
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}
|
||||
|
||||
private serialize(payload: EncryptedField): string {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
private deserialize(serialized: string): EncryptedField {
|
||||
const parsed: unknown = JSON.parse(serialized);
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
typeof (parsed as EncryptedField).ciphertext !== 'string' ||
|
||||
typeof (parsed as EncryptedField).iv !== 'string' ||
|
||||
typeof (parsed as EncryptedField).tag !== 'string'
|
||||
) {
|
||||
throw new Error('Invalid encrypted field payload');
|
||||
}
|
||||
|
||||
return parsed as EncryptedField;
|
||||
}
|
||||
|
||||
private parseKey(): Buffer {
|
||||
const { keyBase64 } = this.configService.get('encryption') as Config['encryption'];
|
||||
|
||||
if (!keyBase64) {
|
||||
throw new Error('Encryption key is not configured');
|
||||
}
|
||||
|
||||
const key = Buffer.from(keyBase64, 'base64');
|
||||
|
||||
if (key.length !== this.encryptionKeyByteLength) {
|
||||
throw new Error(`Encryption key must be a base64-encoded ${this.encryptionKeyByteLength}-byte value`);
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface EncryptedField {
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthCheckController } from './controllers/HealthCheckController';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthCheckController]
|
||||
})
|
||||
export class HealthCheckModule {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get, HttpStatus, Res } from '@nestjs/common';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import type { Response } from 'express';
|
||||
|
||||
@Controller('/health-check')
|
||||
@SkipThrottle({ default: true })
|
||||
export class HealthCheckController {
|
||||
@Get('/')
|
||||
getStatus(@Res() res: Response) {
|
||||
return res.sendStatus(HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/AuthModule';
|
||||
import { MoneroWalletController } from './controllers/MoneroWalletController';
|
||||
import { MoneroWalletAdminService } from './services/MoneroWalletAdminService';
|
||||
import { MoneroWalletRpcClient } from './services/MoneroWalletRpcClient';
|
||||
import { MoneroWalletRpcConnectionService } from './services/MoneroWalletRpcConnectionService';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [MoneroWalletController],
|
||||
providers: [MoneroWalletRpcClient, MoneroWalletRpcConnectionService, MoneroWalletAdminService],
|
||||
exports: [MoneroWalletRpcClient]
|
||||
})
|
||||
export class MoneroWalletModule {}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { JwtGuard } from '../../../guards/JwtGuard';
|
||||
import { throttleProfiles } from '../../../config/throttleProfiles';
|
||||
import { MoneroWalletRevealSeedDto } from '../dto/MoneroWalletRevealSeedDto';
|
||||
import { MoneroWalletWithdrawDto } from '../dto/MoneroWalletWithdrawDto';
|
||||
import { MoneroWalletAdminService } from '../services/MoneroWalletAdminService';
|
||||
|
||||
@Controller('monero-wallet')
|
||||
@UseGuards(JwtGuard)
|
||||
export class MoneroWalletController {
|
||||
constructor(private readonly walletAdminService: MoneroWalletAdminService) {}
|
||||
|
||||
@Get('/')
|
||||
getStatus() {
|
||||
return this.walletAdminService.getStatus();
|
||||
}
|
||||
|
||||
@Post('/withdraw')
|
||||
@Throttle(throttleProfiles.walletWithdraw)
|
||||
withdraw(@Body() { destinationAddress, password }: MoneroWalletWithdrawDto) {
|
||||
return this.walletAdminService.withdrawAll(destinationAddress, password);
|
||||
}
|
||||
|
||||
@Post('/reveal-seed')
|
||||
@Throttle(throttleProfiles.walletRevealSeed)
|
||||
revealSeed(@Body() { password }: MoneroWalletRevealSeedDto) {
|
||||
return this.walletAdminService.revealSeed(password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class MoneroWalletRevealSeedDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { IsMoneroStandardAddress } from '../../../validation/decorators/isMoneroStandardAddress';
|
||||
|
||||
export class MoneroWalletWithdrawDto {
|
||||
@Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsMoneroStandardAddress()
|
||||
destinationAddress: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import type { AuthService } from '../../auth/services/AuthService';
|
||||
import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
|
||||
import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
import { MoneroWalletAdminService } from './MoneroWalletAdminService';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe('MoneroWalletAdminService', () => {
|
||||
let service: MoneroWalletAdminService;
|
||||
let walletRpcClient: {
|
||||
tryRefresh: jest.Mock;
|
||||
getVersion: jest.Mock;
|
||||
getHeight: jest.Mock;
|
||||
getBalance: jest.Mock;
|
||||
sweepAll: jest.Mock;
|
||||
queryMnemonic: jest.Mock;
|
||||
};
|
||||
let authService: {
|
||||
verifyPassword: jest.Mock;
|
||||
};
|
||||
let configService: {
|
||||
get: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
walletRpcClient = {
|
||||
tryRefresh: jest.fn().mockResolvedValue(undefined),
|
||||
getVersion: jest.fn().mockResolvedValue('0.18.3.1'),
|
||||
getHeight: jest.fn().mockResolvedValue(3_000_000),
|
||||
getBalance: jest.fn().mockResolvedValue({
|
||||
balanceAtomic: '2000000000000',
|
||||
unlockedBalanceAtomic: '1000000000000'
|
||||
}),
|
||||
sweepAll: jest.fn().mockResolvedValue({
|
||||
txHashes: ['tx-hash-1'],
|
||||
amountAtomic: '1000000000000'
|
||||
}),
|
||||
queryMnemonic: jest.fn().mockResolvedValue('seed words')
|
||||
};
|
||||
|
||||
authService = {
|
||||
verifyPassword: jest.fn()
|
||||
};
|
||||
|
||||
configService = {
|
||||
get: jest.fn().mockReturnValue({
|
||||
network: 'mainnet',
|
||||
daemonRpcUrl: 'http://daemon.test/json_rpc',
|
||||
rpcTimeoutMs: 5000
|
||||
})
|
||||
};
|
||||
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { result: { height: 3_000_000 } }
|
||||
});
|
||||
|
||||
service = new MoneroWalletAdminService(
|
||||
walletRpcClient as unknown as MoneroWalletRpcClient,
|
||||
authService as unknown as AuthService,
|
||||
configService as unknown as ConfigService
|
||||
);
|
||||
});
|
||||
|
||||
it('returns wallet status when RPC and daemon calls succeed', async () => {
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(walletRpcClient.tryRefresh).toHaveBeenCalled();
|
||||
expect(status).toEqual(
|
||||
expect.objectContaining({
|
||||
network: 'mainnet',
|
||||
rpcVersion: '0.18.3.1',
|
||||
walletHeight: 3_000_000,
|
||||
daemonHeight: 3_000_000,
|
||||
syncStatus: MoneroWalletSyncStatus.Synced,
|
||||
balanceXmr: '2.00000000',
|
||||
unlockedBalanceXmr: '1.00000000'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when wallet status cannot be loaded', async () => {
|
||||
walletRpcClient.getBalance.mockRejectedValue(new Error('rpc down'));
|
||||
|
||||
await expect(service.getStatus()).rejects.toThrow(
|
||||
new ServiceUnavailableException(
|
||||
'Could not load wallet status. The Monero wallet may be busy or unavailable.'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects withdrawals when there is no unlocked balance', async () => {
|
||||
walletRpcClient.getBalance.mockResolvedValue({
|
||||
balanceAtomic: '0',
|
||||
unlockedBalanceAtomic: '0'
|
||||
});
|
||||
|
||||
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
|
||||
new BadRequestException('No unlocked balance to withdraw.')
|
||||
);
|
||||
|
||||
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
|
||||
expect(walletRpcClient.sweepAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects withdrawals while the wallet is still syncing', async () => {
|
||||
walletRpcClient.getHeight.mockResolvedValue(2_999_000);
|
||||
|
||||
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
|
||||
new BadRequestException('Wallet is still syncing. Try again after sync completes.')
|
||||
);
|
||||
});
|
||||
|
||||
it('sweeps unlocked funds when the wallet is synced', async () => {
|
||||
const result = await service.withdrawAll('4DestinationAddressExample', 'password');
|
||||
|
||||
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('4DestinationAddressExample');
|
||||
expect(result).toEqual({
|
||||
txHashes: ['tx-hash-1'],
|
||||
amountXmr: '1.00000000'
|
||||
});
|
||||
});
|
||||
|
||||
it('reveals the wallet seed after password verification', async () => {
|
||||
await expect(service.revealSeed('password')).resolves.toEqual({ mnemonic: 'seed words' });
|
||||
|
||||
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
|
||||
expect(walletRpcClient.queryMnemonic).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports unknown sync status when the daemon height cannot be fetched', async () => {
|
||||
mockedAxios.post.mockRejectedValue(new Error('daemon down'));
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Unknown);
|
||||
expect(status.daemonHeight).toBeNull();
|
||||
});
|
||||
|
||||
it('treats the wallet as synced when it is one block behind the daemon', async () => {
|
||||
walletRpcClient.getHeight.mockResolvedValue(2_999_999);
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { result: { height: 3_000_000 } }
|
||||
});
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Synced);
|
||||
});
|
||||
|
||||
it('throws when reveal seed RPC fails', async () => {
|
||||
walletRpcClient.queryMnemonic.mockRejectedValue(new Error('rpc down'));
|
||||
|
||||
await expect(service.revealSeed('password')).rejects.toThrow(
|
||||
new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.')
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when sweep all fails after prechecks pass', async () => {
|
||||
walletRpcClient.sweepAll.mockRejectedValue(new Error('sweep failed'));
|
||||
|
||||
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
|
||||
new ServiceUnavailableException(
|
||||
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { AuthService } from '../../auth/services/AuthService';
|
||||
import { convertXmrAtomicToXmr } from '../../../utils/monero/convertXmrAtomicToXmr';
|
||||
import type { Config } from '../../../types/Config';
|
||||
import type { MoneroDaemonGetInfoResult } from '../types/MoneroDaemonGetInfoResult';
|
||||
import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult';
|
||||
import type { MoneroWalletStatusView } from '../types/MoneroWalletStatusView';
|
||||
import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
|
||||
import type { MoneroWalletWithdrawResult } from '../types/MoneroWalletWithdrawResult';
|
||||
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
|
||||
@Injectable()
|
||||
export class MoneroWalletAdminService {
|
||||
constructor(
|
||||
private readonly walletRpcClient: MoneroWalletRpcClient,
|
||||
private readonly authService: AuthService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
async getStatus(): Promise<MoneroWalletStatusView> {
|
||||
const { network } = this.configService.get('moneroWallet') as Config['moneroWallet'];
|
||||
|
||||
await this.walletRpcClient.tryRefresh();
|
||||
|
||||
try {
|
||||
const [rpcVersion, daemonHeight, walletHeight, { balanceAtomic, unlockedBalanceAtomic }] =
|
||||
await Promise.all([
|
||||
this.walletRpcClient.getVersion(),
|
||||
this.fetchDaemonHeight(),
|
||||
this.walletRpcClient.getHeight(),
|
||||
this.walletRpcClient.getBalance()
|
||||
]);
|
||||
|
||||
return {
|
||||
network,
|
||||
rpcVersion,
|
||||
walletHeight,
|
||||
daemonHeight,
|
||||
syncStatus: this.resolveSyncStatus(walletHeight, daemonHeight),
|
||||
balanceXmr: convertXmrAtomicToXmr(balanceAtomic),
|
||||
unlockedBalanceXmr: convertXmrAtomicToXmr(unlockedBalanceAtomic)
|
||||
};
|
||||
} catch {
|
||||
throw new ServiceUnavailableException(
|
||||
'Could not load wallet status. The Monero wallet may be busy or unavailable.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async withdrawAll(destinationAddress: string, password: string): Promise<MoneroWalletWithdrawResult> {
|
||||
this.authService.verifyPassword(password);
|
||||
|
||||
await this.walletRpcClient.tryRefresh();
|
||||
|
||||
let unlockedBalanceAtomic: string;
|
||||
let walletHeight: number;
|
||||
let daemonHeight: number | null;
|
||||
|
||||
try {
|
||||
[{ unlockedBalanceAtomic }, walletHeight, daemonHeight] = await Promise.all([
|
||||
this.walletRpcClient.getBalance(),
|
||||
this.walletRpcClient.getHeight(),
|
||||
this.fetchDaemonHeight()
|
||||
]);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.');
|
||||
}
|
||||
|
||||
if (unlockedBalanceAtomic === '0') {
|
||||
throw new BadRequestException('No unlocked balance to withdraw.');
|
||||
}
|
||||
|
||||
if (this.resolveSyncStatus(walletHeight, daemonHeight) !== MoneroWalletSyncStatus.Synced) {
|
||||
throw new BadRequestException('Wallet is still syncing. Try again after sync completes.');
|
||||
}
|
||||
|
||||
let txHashes: string[];
|
||||
let amountAtomic: string;
|
||||
|
||||
try {
|
||||
({ txHashes, amountAtomic } = await this.walletRpcClient.sweepAll(destinationAddress));
|
||||
} catch {
|
||||
throw new ServiceUnavailableException(
|
||||
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
txHashes,
|
||||
amountXmr: convertXmrAtomicToXmr(amountAtomic)
|
||||
};
|
||||
}
|
||||
|
||||
async revealSeed(password: string): Promise<MoneroWalletRevealSeedResult> {
|
||||
this.authService.verifyPassword(password);
|
||||
|
||||
try {
|
||||
const mnemonic = await this.walletRpcClient.queryMnemonic();
|
||||
|
||||
return { mnemonic };
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.');
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchDaemonHeight(): Promise<number | null> {
|
||||
const { daemonRpcUrl, rpcTimeoutMs } = this.configService.get('moneroWallet') as Config['moneroWallet'];
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<{ result?: MoneroDaemonGetInfoResult }>(
|
||||
daemonRpcUrl,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: '0',
|
||||
method: 'get_info'
|
||||
},
|
||||
{ timeout: rpcTimeoutMs }
|
||||
);
|
||||
|
||||
return data.result?.height ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSyncStatus(walletHeight: number, daemonHeight: number | null): MoneroWalletSyncStatus {
|
||||
if (daemonHeight === null) {
|
||||
return MoneroWalletSyncStatus.Unknown;
|
||||
}
|
||||
|
||||
return walletHeight >= daemonHeight - 1 ? MoneroWalletSyncStatus.Synced : MoneroWalletSyncStatus.Syncing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { MoneroWalletRpcClientTest } from '../types/MoneroWalletRpcClientTest';
|
||||
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
|
||||
jest.mock('node:crypto', () => {
|
||||
const actual = jest.requireActual<typeof import('node:crypto')>('node:crypto');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
randomBytes: jest.fn(() => Buffer.from('0123456789abcdef', 'hex'))
|
||||
};
|
||||
});
|
||||
|
||||
describe('MoneroWalletRpcClient', () => {
|
||||
let client: MoneroWalletRpcClientTest;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new MoneroWalletRpcClient({
|
||||
get: jest.fn()
|
||||
} as unknown as ConfigService) as unknown as MoneroWalletRpcClientTest;
|
||||
});
|
||||
|
||||
describe('formatRpcVersion', () => {
|
||||
it('formats the packed RPC version integer from get_version', () => {
|
||||
expect(client.formatRpcVersion(65539)).toBe('1.3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDigestChallenge', () => {
|
||||
it('parses a full digest challenge header', () => {
|
||||
const header = 'Digest realm="monero-rpc", nonce="abc123", opaque="opaque-value", qop="auth"';
|
||||
|
||||
expect(client.parseDigestChallenge(header)).toEqual({
|
||||
realm: 'monero-rpc',
|
||||
nonce: 'abc123',
|
||||
opaque: 'opaque-value',
|
||||
qop: 'auth'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses headers with a lowercase digest prefix', () => {
|
||||
const header = 'digest realm="monero-rpc", nonce="abc123"';
|
||||
|
||||
expect(client.parseDigestChallenge(header)).toEqual({
|
||||
realm: 'monero-rpc',
|
||||
nonce: 'abc123',
|
||||
opaque: undefined,
|
||||
qop: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when realm is missing', () => {
|
||||
expect(() => client.parseDigestChallenge('Digest nonce="abc123"')).toThrow(
|
||||
'Invalid Monero wallet RPC digest challenge'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when nonce is missing', () => {
|
||||
expect(() => client.parseDigestChallenge('Digest realm="monero-rpc"')).toThrow(
|
||||
'Invalid Monero wallet RPC digest challenge'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIncomingTransfers', () => {
|
||||
let rpcClient: MoneroWalletRpcClient;
|
||||
let callSpy: jest.SpiedFunction<(method: string, params?: Record<string, unknown>) => Promise<unknown>>;
|
||||
|
||||
beforeEach(() => {
|
||||
rpcClient = new MoneroWalletRpcClient({
|
||||
get: jest.fn()
|
||||
} as unknown as ConfigService);
|
||||
|
||||
callSpy = jest.spyOn(
|
||||
MoneroWalletRpcClient.prototype as unknown as {
|
||||
call: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
|
||||
},
|
||||
'call'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
callSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns an empty array when the RPC omits in and pool', async () => {
|
||||
callSpy.mockResolvedValue({});
|
||||
|
||||
await expect(rpcClient.getIncomingTransfers([3])).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('maps confirmed and pending transfers when present', async () => {
|
||||
callSpy.mockResolvedValue({
|
||||
in: [
|
||||
{
|
||||
txid: 'confirmed-tx',
|
||||
amount: 1000000000000,
|
||||
confirmations: 2,
|
||||
subaddr_index: { major: 0, minor: 3 }
|
||||
}
|
||||
],
|
||||
pool: [
|
||||
{
|
||||
txid: 'pending-tx',
|
||||
amount: 500000000000,
|
||||
confirmations: 0,
|
||||
subaddr_index: { major: 0, minor: 4 }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await expect(rpcClient.getIncomingTransfers([3, 4])).resolves.toEqual([
|
||||
{
|
||||
txHash: 'confirmed-tx',
|
||||
amountAtomic: '1000000000000',
|
||||
confirmations: 2,
|
||||
subaddrIndex: 3
|
||||
},
|
||||
{
|
||||
txHash: 'pending-tx',
|
||||
amountAtomic: '500000000000',
|
||||
confirmations: 0,
|
||||
subaddrIndex: 4
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults missing confirmations on confirmed transfers to zero', async () => {
|
||||
callSpy.mockResolvedValue({
|
||||
in: [
|
||||
{
|
||||
txid: 'confirmed-tx',
|
||||
amount: 1000000000000,
|
||||
subaddr_index: { major: 0, minor: 3 }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await expect(rpcClient.getIncomingTransfers([3])).resolves.toEqual([
|
||||
{
|
||||
txHash: 'confirmed-tx',
|
||||
amountAtomic: '1000000000000',
|
||||
confirmations: 0,
|
||||
subaddrIndex: 3
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips malformed transfer entries', async () => {
|
||||
callSpy.mockResolvedValue({
|
||||
in: [
|
||||
{ amount: 1, subaddr_index: { major: 0, minor: 1 } },
|
||||
{ txid: 'valid-tx', amount: 2, subaddr_index: { major: 0, minor: 2 } }
|
||||
]
|
||||
});
|
||||
|
||||
await expect(rpcClient.getIncomingTransfers([1, 2])).resolves.toEqual([
|
||||
{
|
||||
txHash: 'valid-tx',
|
||||
amountAtomic: '2',
|
||||
confirmations: 0,
|
||||
subaddrIndex: 2
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDigestAuthorization', () => {
|
||||
it('builds a digest authorization header from the challenge', () => {
|
||||
const username = 'rpcuser';
|
||||
const password = 'rpcpass';
|
||||
const uri = '/json_rpc';
|
||||
const realm = 'monero-rpc';
|
||||
const nonce = 'server-nonce';
|
||||
const qop = 'auth';
|
||||
const nc = '00000001';
|
||||
const cnonce = '0123456789abcdef';
|
||||
const digestHeader = `Digest realm="${realm}", nonce="${nonce}", qop="${qop}"`;
|
||||
const ha1 = createHash('md5').update(`${username}:${realm}:${password}`).digest('hex');
|
||||
const ha2 = createHash('md5').update(`POST:${uri}`).digest('hex');
|
||||
const response = createHash('md5').update(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`).digest('hex');
|
||||
|
||||
expect(client.buildDigestAuthorization(uri, username, password, digestHeader)).toBe(
|
||||
`Digest username="${username}", realm="${realm}", nonce="${nonce}", uri="${uri}", qop=${qop}, nc=${nc}, cnonce="${cnonce}", response="${response}"`
|
||||
);
|
||||
});
|
||||
|
||||
it('includes opaque when the challenge provides it', () => {
|
||||
const digestHeader = 'Digest realm="monero-rpc", nonce="server-nonce", opaque="opaque-token", qop="auth"';
|
||||
|
||||
const authorization = client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', digestHeader);
|
||||
|
||||
expect(authorization).toContain('opaque="opaque-token"');
|
||||
});
|
||||
|
||||
it('defaults qop to auth when the challenge omits it', () => {
|
||||
const digestHeader = 'Digest realm="monero-rpc", nonce="server-nonce"';
|
||||
|
||||
const authorization = client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', digestHeader);
|
||||
|
||||
expect(authorization).toContain('qop=auth');
|
||||
});
|
||||
|
||||
it('uses the first qop option when several are offered', () => {
|
||||
const digestHeader = 'Digest realm="monero-rpc", nonce="server-nonce", qop="auth, auth-int"';
|
||||
|
||||
const authorization = client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', digestHeader);
|
||||
|
||||
expect(authorization).toContain('qop=auth');
|
||||
expect(authorization).not.toContain('auth-int');
|
||||
});
|
||||
|
||||
it('throws when the challenge header is invalid', () => {
|
||||
expect(() =>
|
||||
client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', 'Digest qop="auth"')
|
||||
).toThrow('Invalid Monero wallet RPC digest challenge');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import type { Config } from '../../../types/Config';
|
||||
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||
import type { MoneroCreateAddressResult } from '../types/MoneroCreateAddressResult';
|
||||
import type { MoneroWalletRpcIncomingTransfer } from '../types/MoneroWalletRpcIncomingTransfer';
|
||||
import type { MoneroWalletRpcGetTransfersResult } from '../types/MoneroWalletRpcGetTransfersResult';
|
||||
import type { MoneroWalletRpcTransferEntry } from '../types/MoneroWalletRpcTransferEntry';
|
||||
import type { MoneroWalletRpcGetBalanceResult } from '../types/MoneroWalletRpcGetBalanceResult';
|
||||
import type { MoneroWalletRpcGetHeightResult } from '../types/MoneroWalletRpcGetHeightResult';
|
||||
import type { MoneroWalletRpcGetVersionResult } from '../types/MoneroWalletRpcGetVersionResult';
|
||||
import type { MoneroWalletRpcQueryKeyResult } from '../types/MoneroWalletRpcQueryKeyResult';
|
||||
import type { MoneroWalletRpcSweepAllResult } from '../types/MoneroWalletRpcSweepAllResult';
|
||||
import type { MoneroWalletRpcDigestChallenge } from '../types/MoneroWalletRpcDigestChallenge';
|
||||
import type { MoneroWalletRpcResponse } from '../types/MoneroWalletRpcResponse';
|
||||
|
||||
@Injectable()
|
||||
export class MoneroWalletRpcClient {
|
||||
private readonly logger = new Logger(MoneroWalletRpcClient.name);
|
||||
private readonly accountIndex = 0;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
private async call<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
options: { timeoutMs?: number } = {}
|
||||
): Promise<T> {
|
||||
const { rpcUrl, rpcTimeoutMs } = this.configService.get('moneroWallet') as Config['moneroWallet'];
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? rpcTimeoutMs;
|
||||
|
||||
const body = { method, params };
|
||||
|
||||
const authorization = await this.doDigestAuthorization(body);
|
||||
|
||||
const { data } = await axios.post<MoneroWalletRpcResponse<T>>(rpcUrl, body, {
|
||||
timeout: timeoutMs,
|
||||
headers: { Authorization: authorization }
|
||||
});
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message);
|
||||
}
|
||||
|
||||
if (data.result === undefined) {
|
||||
throw new Error(`Monero wallet RPC ${method} returned no result`);
|
||||
}
|
||||
|
||||
return data.result;
|
||||
}
|
||||
|
||||
private async doDigestAuthorization(body: { method: string; params: Record<string, unknown> }): Promise<string> {
|
||||
const { rpcUrl, username, password, rpcTimeoutMs } = this.configService.get(
|
||||
'moneroWallet'
|
||||
) as Config['moneroWallet'];
|
||||
|
||||
const rpcUri = new URL(rpcUrl);
|
||||
const requestPath = `${rpcUri.pathname}${rpcUri.search}`;
|
||||
|
||||
const challengeResponse = await axios.post(rpcUrl, body, {
|
||||
timeout: rpcTimeoutMs,
|
||||
validateStatus: (status: HttpStatus) => status === HttpStatus.UNAUTHORIZED
|
||||
});
|
||||
|
||||
const digestHeader = challengeResponse.headers['www-authenticate'] as unknown;
|
||||
|
||||
if (!digestHeader || typeof digestHeader !== 'string') {
|
||||
throw new Error(`Monero wallet RPC ${body.method} did not return a digest auth challenge`);
|
||||
}
|
||||
|
||||
return this.buildDigestAuthorization(requestPath, username, password, digestHeader);
|
||||
}
|
||||
|
||||
private buildDigestAuthorization(uri: string, username: string, password: string, digestHeader: string): string {
|
||||
const challenge = this.parseDigestChallenge(digestHeader);
|
||||
const ha1 = createHash('md5').update(`${username}:${challenge.realm}:${password}`).digest('hex');
|
||||
const ha2 = createHash('md5').update(`POST:${uri}`).digest('hex');
|
||||
const nc = '00000001';
|
||||
const cnonce = randomBytes(8).toString('hex');
|
||||
const qop = challenge.qop?.split(',')[0]?.trim() || 'auth';
|
||||
const response = createHash('md5')
|
||||
.update(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${qop}:${ha2}`)
|
||||
.digest('hex');
|
||||
|
||||
const parts = [
|
||||
`username="${username}"`,
|
||||
`realm="${challenge.realm}"`,
|
||||
`nonce="${challenge.nonce}"`,
|
||||
`uri="${uri}"`,
|
||||
`qop=${qop}`,
|
||||
`nc=${nc}`,
|
||||
`cnonce="${cnonce}"`,
|
||||
`response="${response}"`
|
||||
];
|
||||
|
||||
if (challenge.opaque) {
|
||||
parts.push(`opaque="${challenge.opaque}"`);
|
||||
}
|
||||
|
||||
return `Digest ${parts.join(', ')}`;
|
||||
}
|
||||
|
||||
private parseDigestChallenge(header: string): MoneroWalletRpcDigestChallenge {
|
||||
const params = Object.fromEntries(
|
||||
header
|
||||
.replace(/^Digest\s+/i, '')
|
||||
.split(',')
|
||||
.map(part => {
|
||||
const [key, ...valueParts] = part.trim().split('=');
|
||||
|
||||
return [key, valueParts.join('=').replace(/^"|"$/g, '')];
|
||||
})
|
||||
);
|
||||
|
||||
if (!params.realm || !params.nonce) {
|
||||
throw new Error('Invalid Monero wallet RPC digest challenge');
|
||||
}
|
||||
|
||||
return {
|
||||
realm: params.realm,
|
||||
nonce: params.nonce,
|
||||
opaque: params.opaque,
|
||||
qop: params.qop
|
||||
};
|
||||
}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
const { version, release } = await this.call<MoneroWalletRpcGetVersionResult>('get_version');
|
||||
|
||||
if (version === undefined) {
|
||||
throw new Error('Monero wallet RPC get_version returned no version');
|
||||
}
|
||||
|
||||
const formatted = this.formatRpcVersion(version);
|
||||
|
||||
if (release === false) {
|
||||
return `${formatted} (non-release)`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
private formatRpcVersion(version: number): string {
|
||||
const major = version >>> 16;
|
||||
const minor = version & 0xffff;
|
||||
|
||||
return `${major}.${minor}`;
|
||||
}
|
||||
|
||||
async createAddress(label?: string): Promise<{ address: string; address_index: number }> {
|
||||
const params: { account_index: number; label?: string } = { account_index: this.accountIndex };
|
||||
|
||||
if (label) {
|
||||
params.label = label;
|
||||
}
|
||||
|
||||
const result = await this.call<MoneroCreateAddressResult>('create_address', params);
|
||||
|
||||
if (!result.address || result.address_index === undefined) {
|
||||
throw new Error('Monero wallet RPC create_address returned incomplete result');
|
||||
}
|
||||
|
||||
return { address: result.address, address_index: result.address_index };
|
||||
}
|
||||
|
||||
async getIncomingTransfers(subaddrIndices: number[]): Promise<MoneroWalletRpcIncomingTransfer[]> {
|
||||
if (subaddrIndices.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = await this.call<MoneroWalletRpcGetTransfersResult>('get_transfers', {
|
||||
in: true,
|
||||
pool: true,
|
||||
account_index: this.accountIndex,
|
||||
subaddr_indices: subaddrIndices
|
||||
});
|
||||
|
||||
const confirmed = (result.in ?? [])
|
||||
.map(transfer => this.mapIncomingTransfer(transfer, transfer.confirmations ?? 0))
|
||||
.filter((transfer): transfer is MoneroWalletRpcIncomingTransfer => transfer !== null);
|
||||
|
||||
const pending = (result.pool ?? [])
|
||||
.map(transfer => this.mapIncomingTransfer(transfer, 0))
|
||||
.filter((transfer): transfer is MoneroWalletRpcIncomingTransfer => transfer !== null);
|
||||
|
||||
return [...confirmed, ...pending];
|
||||
}
|
||||
|
||||
async tryRefresh(): Promise<void> {
|
||||
try {
|
||||
await this.call('refresh');
|
||||
} catch (error) {
|
||||
this.logger.warn(`Monero wallet refresh failed: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getHeight(): Promise<number> {
|
||||
const { height } = await this.call<MoneroWalletRpcGetHeightResult>('get_height');
|
||||
|
||||
if (height === undefined) {
|
||||
throw new Error('Monero wallet RPC get_height returned no height');
|
||||
}
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
async getBalance(): Promise<{ balanceAtomic: string; unlockedBalanceAtomic: string }> {
|
||||
const { balance, unlocked_balance } = await this.call<MoneroWalletRpcGetBalanceResult>('get_balance', {
|
||||
account_index: this.accountIndex
|
||||
});
|
||||
|
||||
if (balance === undefined || unlocked_balance === undefined) {
|
||||
throw new Error('Monero wallet RPC get_balance returned incomplete result');
|
||||
}
|
||||
|
||||
return {
|
||||
balanceAtomic: String(balance),
|
||||
unlockedBalanceAtomic: String(unlocked_balance)
|
||||
};
|
||||
}
|
||||
|
||||
async sweepAll(destinationAddress: string): Promise<{ txHashes: string[]; amountAtomic: string }> {
|
||||
const result = await this.call<MoneroWalletRpcSweepAllResult>(
|
||||
'sweep_all',
|
||||
{
|
||||
address: destinationAddress,
|
||||
account_index: this.accountIndex,
|
||||
subaddr_indices_all: true,
|
||||
priority: 1
|
||||
},
|
||||
{ timeoutMs: 120_000 }
|
||||
);
|
||||
|
||||
const txHashes = result.tx_hash_list;
|
||||
|
||||
if (!txHashes?.length) {
|
||||
throw new Error('Monero wallet RPC sweep_all returned no transaction hashes');
|
||||
}
|
||||
|
||||
const sweptAmount = (result.amount_list ?? []).reduce((sum, amount) => sum + amount, 0);
|
||||
|
||||
return {
|
||||
txHashes,
|
||||
amountAtomic: String(sweptAmount)
|
||||
};
|
||||
}
|
||||
|
||||
async queryMnemonic(): Promise<string> {
|
||||
const { key } = await this.call<MoneroWalletRpcQueryKeyResult>('query_key', {
|
||||
key_type: 'mnemonic'
|
||||
});
|
||||
|
||||
const trimmedKey = key?.trim();
|
||||
|
||||
if (!trimmedKey) {
|
||||
throw new Error('Monero wallet RPC query_key returned no mnemonic');
|
||||
}
|
||||
|
||||
return trimmedKey;
|
||||
}
|
||||
|
||||
private mapIncomingTransfer(
|
||||
transfer: MoneroWalletRpcTransferEntry,
|
||||
confirmations: number
|
||||
): MoneroWalletRpcIncomingTransfer | null {
|
||||
const txHash = transfer.txid?.trim();
|
||||
|
||||
if (!txHash || transfer.amount === undefined || transfer.subaddr_index?.minor === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
txHash,
|
||||
amountAtomic: String(transfer.amount),
|
||||
confirmations,
|
||||
subaddrIndex: transfer.subaddr_index.minor
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
import { MoneroWalletRpcConnectionService } from './MoneroWalletRpcConnectionService';
|
||||
|
||||
describe('MoneroWalletRpcConnectionService', () => {
|
||||
let service: MoneroWalletRpcConnectionService;
|
||||
let walletRpcClient: {
|
||||
getVersion: jest.Mock;
|
||||
};
|
||||
let logSpy: jest.SpiedFunction<typeof Logger.prototype.log>;
|
||||
let errorSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
errorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
walletRpcClient = {
|
||||
getVersion: jest.fn().mockResolvedValue('0.18.3.1')
|
||||
};
|
||||
|
||||
service = new MoneroWalletRpcConnectionService(walletRpcClient as unknown as MoneroWalletRpcClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs a successful wallet rpc connection on module init', async () => {
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(walletRpcClient.getVersion).toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledWith('Connected to monero-wallet-rpc (version 0.18.3.1)');
|
||||
});
|
||||
|
||||
it('logs an error when wallet rpc is unreachable at startup', async () => {
|
||||
walletRpcClient.getVersion.mockRejectedValue(new Error('connection refused'));
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith('Failed to reach monero-wallet-rpc at startup: connection refused');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
|
||||
@Injectable()
|
||||
export class MoneroWalletRpcConnectionService implements OnModuleInit {
|
||||
private readonly logger = new Logger(MoneroWalletRpcConnectionService.name);
|
||||
|
||||
constructor(private readonly walletRpcClient: MoneroWalletRpcClient) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
const version = await this.walletRpcClient.getVersion();
|
||||
|
||||
this.logger.log(`Connected to monero-wallet-rpc (version ${version})`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to reach monero-wallet-rpc at startup: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type MoneroCreateAddressResult = {
|
||||
address?: string;
|
||||
address_index?: number;
|
||||
address_indices?: number[];
|
||||
addresses?: string[];
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MoneroDaemonGetInfoResult {
|
||||
height?: number;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MoneroWalletRevealSeedResult {
|
||||
mnemonic: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { MoneroWalletRpcDigestChallenge } from './MoneroWalletRpcDigestChallenge';
|
||||
|
||||
export type MoneroWalletRpcClientTest = {
|
||||
formatRpcVersion(version: number): string;
|
||||
parseDigestChallenge(header: string): MoneroWalletRpcDigestChallenge;
|
||||
buildDigestAuthorization(uri: string, username: string, password: string, digestHeader: string): string;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type MoneroWalletRpcDigestChallenge = {
|
||||
realm: string;
|
||||
nonce: string;
|
||||
opaque?: string;
|
||||
qop?: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type MoneroWalletRpcError = {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface MoneroWalletRpcGetBalanceResult {
|
||||
balance?: number;
|
||||
unlocked_balance?: number;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MoneroWalletRpcGetHeightResult {
|
||||
height?: number;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { MoneroWalletRpcTransferEntry } from './MoneroWalletRpcTransferEntry';
|
||||
|
||||
export type MoneroWalletRpcGetTransfersResult = {
|
||||
in?: MoneroWalletRpcTransferEntry[];
|
||||
out?: MoneroWalletRpcTransferEntry[];
|
||||
pending?: MoneroWalletRpcTransferEntry[];
|
||||
failed?: MoneroWalletRpcTransferEntry[];
|
||||
pool?: MoneroWalletRpcTransferEntry[];
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type MoneroWalletRpcGetVersionResult = {
|
||||
version?: number;
|
||||
release?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type MoneroWalletRpcIncomingTransfer = {
|
||||
txHash: string;
|
||||
amountAtomic: string;
|
||||
confirmations: number;
|
||||
subaddrIndex: number;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MoneroWalletRpcQueryKeyResult {
|
||||
key?: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { MoneroWalletRpcError } from './MoneroWalletRpcError';
|
||||
|
||||
export type MoneroWalletRpcResponse<T> = {
|
||||
id: string;
|
||||
jsonrpc: string;
|
||||
result?: T;
|
||||
error?: MoneroWalletRpcError;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type MoneroWalletRpcSubaddrIndex = {
|
||||
major?: number;
|
||||
minor?: number;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface MoneroWalletRpcSweepAllResult {
|
||||
tx_hash_list?: string[];
|
||||
amount_list?: number[];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MoneroWalletRpcSubaddrIndex } from './MoneroWalletRpcSubaddrIndex';
|
||||
|
||||
export type MoneroWalletRpcTransferEntry = {
|
||||
txid?: string;
|
||||
amount?: number;
|
||||
confirmations?: number;
|
||||
subaddr_index?: MoneroWalletRpcSubaddrIndex;
|
||||
address?: string;
|
||||
height?: number;
|
||||
timestamp?: number;
|
||||
type?: string;
|
||||
locked?: boolean;
|
||||
amounts?: number[];
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MoneroNetwork } from '../../../types/MoneroNetwork';
|
||||
import { MoneroWalletSyncStatus } from './MoneroWalletSyncStatus';
|
||||
|
||||
export interface MoneroWalletStatusView {
|
||||
network: MoneroNetwork;
|
||||
rpcVersion: string;
|
||||
walletHeight: number;
|
||||
daemonHeight: number | null;
|
||||
syncStatus: MoneroWalletSyncStatus;
|
||||
balanceXmr: string;
|
||||
unlockedBalanceXmr: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum MoneroWalletSyncStatus {
|
||||
Synced = 'synced',
|
||||
Syncing = 'syncing',
|
||||
Unknown = 'unknown'
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface MoneroWalletWithdrawResult {
|
||||
txHashes: string[];
|
||||
amountXmr: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule';
|
||||
import { SimplexModule } from '../simplex/SimplexModule';
|
||||
import { NotificationService } from './services/NotificationService';
|
||||
|
||||
@Module({
|
||||
imports: [SimplexModule, ShopSettingsModule],
|
||||
providers: [NotificationService],
|
||||
exports: [NotificationService]
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
|
||||
import type { SimplexChatClient } from '../../simplex/services/SimplexChatClient';
|
||||
import { NotificationService } from './NotificationService';
|
||||
|
||||
describe('NotificationService', () => {
|
||||
let service: NotificationService;
|
||||
let shopSettingsService: {
|
||||
findSettings: jest.Mock;
|
||||
};
|
||||
let simplexChatClient: {
|
||||
sendText: jest.Mock;
|
||||
};
|
||||
let warnSpy: jest.SpiedFunction<typeof Logger.prototype.warn>;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
shopSettingsService = {
|
||||
findSettings: jest.fn().mockResolvedValue({
|
||||
notificationsEnabled: true,
|
||||
simplexNotificationContactId: 42,
|
||||
notifyOnNewOrder: true,
|
||||
notifyOnOrderMessage: false
|
||||
})
|
||||
};
|
||||
|
||||
simplexChatClient = {
|
||||
sendText: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
|
||||
service = new NotificationService(
|
||||
shopSettingsService as unknown as ShopSettingsService,
|
||||
simplexChatClient as unknown as SimplexChatClient
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does nothing when notifications are disabled', async () => {
|
||||
shopSettingsService.findSettings.mockResolvedValue({
|
||||
notificationsEnabled: false,
|
||||
simplexNotificationContactId: 42,
|
||||
notifyOnNewOrder: true,
|
||||
notifyOnOrderMessage: true
|
||||
});
|
||||
|
||||
await service.sendNotification('order-1234-abcd', 'newOrder');
|
||||
|
||||
expect(simplexChatClient.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the notification type is disabled', async () => {
|
||||
await service.sendNotification('order-1234-abcd', 'newBuyerMessage');
|
||||
|
||||
expect(simplexChatClient.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a new order notification when enabled', async () => {
|
||||
await service.sendNotification('order-1234-abcd', 'newOrder');
|
||||
|
||||
expect(simplexChatClient.sendText).toHaveBeenCalledWith(42, 'New order #orde — open CMS Orders.');
|
||||
});
|
||||
|
||||
it('logs a warning when sending fails without throwing', async () => {
|
||||
simplexChatClient.sendText.mockRejectedValue(new Error('simplex down'));
|
||||
|
||||
await expect(service.sendNotification('order-1234-abcd', 'newOrder')).resolves.toBeUndefined();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Failed to send newOrder notification: simplex down');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||
import { formatShortOrderId } from '../../../utils/order/formatShortOrderId';
|
||||
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
|
||||
import { SimplexChatClient } from '../../simplex/services/SimplexChatClient';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
private readonly logger = new Logger(NotificationService.name);
|
||||
|
||||
constructor(
|
||||
private readonly shopSettingsService: ShopSettingsService,
|
||||
private readonly simplexChatClient: SimplexChatClient
|
||||
) {}
|
||||
|
||||
async sendNotification(orderId: string, type: 'newOrder' | 'newBuyerMessage'): Promise<void> {
|
||||
try {
|
||||
const settings = await this.shopSettingsService.findSettings();
|
||||
|
||||
if (!settings.notificationsEnabled || settings.simplexNotificationContactId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = type === 'newOrder' ? settings.notifyOnNewOrder : settings.notifyOnOrderMessage;
|
||||
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortId = formatShortOrderId(orderId);
|
||||
|
||||
const message =
|
||||
type === 'newOrder'
|
||||
? `New order ${shortId} — open CMS Orders.`
|
||||
: `New message on order ${shortId}.`;
|
||||
|
||||
await this.simplexChatClient.sendText(settings.simplexNotificationContactId, message);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to send ${type} notification: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { EncryptionModule } from '../encryption/EncryptionModule';
|
||||
import { NotificationsModule } from '../notifications/NotificationsModule';
|
||||
import { PaymentModule } from '../payment/PaymentModule';
|
||||
import { OrderChatController } from './controllers/OrderChatController';
|
||||
import { OrdersController } from './controllers/OrdersController';
|
||||
import { Order } from './entities/Order';
|
||||
import { OrderLineAutoFulfillmentItemAttachment } from './entities/OrderLineAutoFulfillmentItemAttachment';
|
||||
import { OrderLineManualFulfillment } from './entities/OrderLineManualFulfillment';
|
||||
import { OrderMessage } from './entities/OrderMessage';
|
||||
import { OrderClaimService } from './services/OrderClaimService';
|
||||
import { OrderAccessTokenService } from './services/OrderAccessTokenService';
|
||||
import { OrderCreationService } from './services/OrderCreationService';
|
||||
import { OrderChatService } from './services/OrderChatService';
|
||||
import { OrderService } from './services/OrderService';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Order,
|
||||
OrderMessage,
|
||||
OrderLineAutoFulfillmentItemAttachment,
|
||||
OrderLineManualFulfillment
|
||||
]),
|
||||
EncryptionModule,
|
||||
PaymentModule,
|
||||
NotificationsModule
|
||||
],
|
||||
controllers: [OrdersController, OrderChatController],
|
||||
providers: [OrderService, OrderClaimService, OrderCreationService, OrderAccessTokenService, OrderChatService],
|
||||
exports: [
|
||||
OrderService,
|
||||
OrderCreationService,
|
||||
OrderAccessTokenService,
|
||||
OrderChatService,
|
||||
TypeOrmModule.forFeature([Order, OrderLineAutoFulfillmentItemAttachment])
|
||||
]
|
||||
})
|
||||
export class OrderModule {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user