This commit is contained in:
2026-08-28 17:31:02 +02:00
commit 2b30e8bd39
694 changed files with 49243 additions and 0 deletions
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SimplexModule } from '../simplex/SimplexModule';
import { ShopSettingsController } from './controllers/ShopSettingsController';
import { ShopSettings } from './entities/ShopSettings';
import { ShopSettingsService } from './services/ShopSettingsService';
@Module({
imports: [TypeOrmModule.forFeature([ShopSettings]), SimplexModule],
controllers: [ShopSettingsController],
providers: [ShopSettingsService],
exports: [ShopSettingsService]
})
export class ShopSettingsModule {}
@@ -0,0 +1,12 @@
import { getShopFaviconMulterConfig } from '../../../config';
import { getShopBrandingDir } from '../../../config/uploadPaths';
import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions';
import { createUploadFilePipe } from '../../../utils/createUploadFilePipe';
const uploadDir = getShopBrandingDir();
export const shopFaviconUploadOptions = createDiskStorageUploadOptions(uploadDir);
const { allowedMimes, maxFileBytes } = getShopFaviconMulterConfig();
export const shopFaviconFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes);
@@ -0,0 +1,12 @@
import { getShopLogoMulterConfig } from '../../../config';
import { getShopBrandingDir } from '../../../config/uploadPaths';
import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions';
import { createUploadFilePipe } from '../../../utils/createUploadFilePipe';
const uploadDir = getShopBrandingDir();
export const shopLogoUploadOptions = createDiskStorageUploadOptions(uploadDir);
const { allowedMimes, maxFileBytes } = getShopLogoMulterConfig();
export const shopLogoFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes);
@@ -0,0 +1,62 @@
import {
Body,
Controller,
Get,
Patch,
Post,
UploadedFile,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { JwtGuard } from '../../../guards/JwtGuard';
import { shopFaviconFilePipe, shopFaviconUploadOptions } from '../config/shopFaviconUpload';
import { shopLogoFilePipe, shopLogoUploadOptions } from '../config/shopLogoUpload';
import { ConnectSimplexNotificationsDto } from '../dto/ConnectSimplexNotificationsDto';
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
import { ShopSettingsService } from '../services/ShopSettingsService';
@Controller('shop-settings')
@UseGuards(JwtGuard)
export class ShopSettingsController {
constructor(private readonly shopSettingsService: ShopSettingsService) {}
@Get('/')
get() {
return this.shopSettingsService.getView();
}
@Patch('/simplex-link')
updateSimplexLink(@Body() dto: UpdateSimplexLinkDto) {
return this.shopSettingsService.updateSimplexLink(dto);
}
@Patch('/shipping-note')
updateShippingNote(@Body() dto: UpdateShippingNoteDto) {
return this.shopSettingsService.updateShippingNote(dto);
}
@Patch('/notifications')
updateNotifications(@Body() dto: UpdateNotificationsDto) {
return this.shopSettingsService.updateNotifications(dto);
}
@Post('/simplex-connect')
connectSimplexNotifications(@Body() dto: ConnectSimplexNotificationsDto) {
return this.shopSettingsService.connectSimplexNotifications(dto.simplexNotificationLink);
}
@Post('/logo')
@UseInterceptors(FileInterceptor('file', shopLogoUploadOptions))
uploadLogo(@UploadedFile(shopLogoFilePipe) file: Express.Multer.File) {
return this.shopSettingsService.uploadLogo(file.filename);
}
@Post('/favicon')
@UseInterceptors(FileInterceptor('file', shopFaviconUploadOptions))
uploadFavicon(@UploadedFile(shopFaviconFilePipe) file: Express.Multer.File) {
return this.shopSettingsService.uploadFavicon(file.filename);
}
}
@@ -0,0 +1,8 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class ConnectSimplexNotificationsDto {
@IsNotEmpty()
@IsString()
@MaxLength(512)
simplexNotificationLink: string;
}
@@ -0,0 +1,15 @@
import { IsBoolean, IsNotEmpty } from 'class-validator';
export class UpdateNotificationsDto {
@IsNotEmpty()
@IsBoolean()
notificationsEnabled: boolean;
@IsNotEmpty()
@IsBoolean()
notifyOnNewOrder: boolean;
@IsNotEmpty()
@IsBoolean()
notifyOnOrderMessage: boolean;
}
@@ -0,0 +1,15 @@
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
import { getAppConfig } from '../../../config';
const {
validation: { shippingNoteMinLength, shippingNoteMaxLength }
} = getAppConfig();
export class UpdateShippingNoteDto {
@IsNotEmpty()
@IsString()
@MinLength(shippingNoteMinLength)
@MaxLength(shippingNoteMaxLength)
shippingNote: string;
}
@@ -0,0 +1,8 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class UpdateSimplexLinkDto {
@IsNotEmpty()
@IsString()
@MaxLength(512)
simplexLink: string;
}
@@ -0,0 +1,40 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('shop_settings')
export class ShopSettings {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', nullable: true })
logoStorageKey: string | null;
@Column({ type: 'varchar', nullable: true })
faviconStorageKey: string | null;
@Column({ type: 'varchar', nullable: true })
simplexLink: string | null;
@Column({ type: 'varchar', nullable: true })
simplexNotificationLink: string | null;
@Column({ type: 'text', nullable: true })
shippingNote: string | null;
@Column({ type: 'boolean', default: false })
notificationsEnabled: boolean;
@Column({ type: 'boolean', default: true })
notifyOnNewOrder: boolean;
@Column({ type: 'boolean', default: true })
notifyOnOrderMessage: boolean;
@Column({ type: 'integer', nullable: true })
simplexNotificationContactId: number | null;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,81 @@
import type { ConfigService } from '@nestjs/config';
import type { Repository } from 'typeorm';
import type { SimplexChatClient } from '../../simplex/services/SimplexChatClient';
import { ShopSettings } from '../entities/ShopSettings';
import { ShopSettingsService } from './ShopSettingsService';
describe('ShopSettingsService', () => {
let service: ShopSettingsService;
let shopSettingsRepo: {
find: jest.Mock;
update: jest.Mock;
};
let configService: {
get: jest.Mock;
};
const settings = {
id: 'settings-1',
logoStorageKey: 'logo.png',
faviconStorageKey: null,
simplexLink: 'https://simplex.example',
simplexNotificationLink: null,
shippingNote: 'Ships in 3 days',
notificationsEnabled: false,
notifyOnNewOrder: false,
notifyOnOrderMessage: false,
simplexNotificationContactId: null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z')
} as ShopSettings;
beforeEach(() => {
shopSettingsRepo = {
find: jest.fn().mockResolvedValue([settings]),
update: jest.fn().mockResolvedValue(undefined)
};
configService = {
get: jest.fn((key: string) => {
if (key === 'shopSettings') {
return { shopName: 'Test Shop', shopFiatCurrency: 'USD', monero: {} };
}
if (key === 'app') {
return { validation: { shippingNoteMinLength: 10 } };
}
return undefined;
})
};
service = new ShopSettingsService(
shopSettingsRepo as unknown as Repository<ShopSettings>,
configService as unknown as ConfigService,
{} as unknown as SimplexChatClient
);
});
it('throws when shop settings have not been initialized', async () => {
shopSettingsRepo.find.mockResolvedValue([]);
await expect(service.findSettings()).rejects.toThrow('Shop settings have not been initialized');
});
it('returns storefront branding urls and public fields', async () => {
await expect(service.getStorefrontBranding()).resolves.toEqual({
logoUrl: '/uploads/public/shop/logo.png',
faviconUrl: null,
simplexLink: 'https://simplex.example',
shippingNote: 'Ships in 3 days'
});
});
it('trims simplex links on update', async () => {
await service.updateSimplexLink({ simplexLink: ' https://simplex.example/new ' });
expect(shopSettingsRepo.update).toHaveBeenCalledWith('settings-1', {
simplexLink: 'https://simplex.example/new'
});
});
});
@@ -0,0 +1,248 @@
import { BadGatewayException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { getShopBrandingPublicUrl, resolveShopBrandingPath } from '../../../config/uploadPaths';
import { Config } from '../../../types/Config';
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
import { SimplexChatClient } from '../../simplex/services/SimplexChatClient';
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
import { ShopSettings } from '../entities/ShopSettings';
import { SetupChecklist } from '../types/SetupChecklist';
import { ShopSettingsView } from '../types/ShopSettingsView';
import { StorefrontBranding } from '../types/StorefrontBranding';
@Injectable()
export class ShopSettingsService {
private readonly logger = new Logger(ShopSettingsService.name);
private connectingSimplexPromise: Promise<ShopSettingsView> | null = null;
constructor(
@InjectRepository(ShopSettings)
private readonly shopSettingsRepo: Repository<ShopSettings>,
private readonly configService: ConfigService,
private readonly simplexChatClient: SimplexChatClient
) {}
async getView(): Promise<ShopSettingsView> {
const settings = await this.findSettings();
return this.toView(settings);
}
async getStorefrontBranding(): Promise<StorefrontBranding> {
const settings = await this.findSettings();
const logoUrl = settings.logoStorageKey ? getShopBrandingPublicUrl(settings.logoStorageKey) : null;
const faviconUrl = settings.faviconStorageKey ? getShopBrandingPublicUrl(settings.faviconStorageKey) : null;
return {
logoUrl,
faviconUrl,
simplexLink: settings.simplexLink,
shippingNote: settings.shippingNote
};
}
async findSettings(): Promise<ShopSettings> {
const [settings] = await this.shopSettingsRepo.find({ take: 1 });
if (!settings) {
throw new Error('Shop settings have not been initialized');
}
return settings;
}
async updateSimplexLink({ simplexLink }: UpdateSimplexLinkDto): Promise<ShopSettingsView> {
const settings = await this.findSettings();
await this.shopSettingsRepo.update(settings.id, {
simplexLink: simplexLink.trim()
});
return this.getView();
}
async updateShippingNote({ shippingNote }: UpdateShippingNoteDto): Promise<ShopSettingsView> {
const settings = await this.findSettings();
await this.shopSettingsRepo.update(settings.id, {
shippingNote: shippingNote.trim()
});
return this.getView();
}
async updateNotifications({
notificationsEnabled,
notifyOnNewOrder,
notifyOnOrderMessage
}: UpdateNotificationsDto): Promise<ShopSettingsView> {
const settings = await this.findSettings();
await this.shopSettingsRepo.update(settings.id, {
notificationsEnabled,
notifyOnNewOrder,
notifyOnOrderMessage
});
return this.getView();
}
async uploadLogo(filename: string): Promise<ShopSettingsView> {
const settings = await this.findSettings();
const previousLogoStorageKey = settings.logoStorageKey;
await this.shopSettingsRepo.update(settings.id, { logoStorageKey: filename });
if (previousLogoStorageKey) {
const previousLogoPath = resolveShopBrandingPath(previousLogoStorageKey);
await removeFileFromDisk(previousLogoPath, ShopSettingsService.name);
}
return this.getView();
}
async uploadFavicon(filename: string): Promise<ShopSettingsView> {
const settings = await this.findSettings();
const previousFaviconStorageKey = settings.faviconStorageKey;
await this.shopSettingsRepo.update(settings.id, { faviconStorageKey: filename });
if (previousFaviconStorageKey) {
const previousFaviconPath = resolveShopBrandingPath(previousFaviconStorageKey);
await removeFileFromDisk(previousFaviconPath, ShopSettingsService.name);
}
return this.getView();
}
async connectSimplexNotifications(simplexNotificationLink: string): Promise<ShopSettingsView> {
if (this.connectingSimplexPromise) {
return this.connectingSimplexPromise;
}
this.connectingSimplexPromise = this.runConnectSimplexNotifications(simplexNotificationLink).finally(() => {
this.connectingSimplexPromise = null;
});
return this.connectingSimplexPromise;
}
private async runConnectSimplexNotifications(simplexNotificationLink: string): Promise<ShopSettingsView> {
const trimmedLink = simplexNotificationLink.trim();
try {
await this.updateSimplexNotificationLink(trimmedLink);
const contactId = await this.simplexChatClient.connect(trimmedLink);
await this.setSimplexNotificationContactId(contactId);
this.logger.log(`SimpleX notification contact ready (id=${contactId})`);
return this.getView();
} catch {
await this.setSimplexNotificationContactId(null);
this.logger.warn('Failed to connect SimpleX notification contact');
throw new BadGatewayException('Failed to connect to SimpleX');
}
}
async updateSimplexNotificationLink(simplexNotificationLink: string): Promise<void> {
const settings = await this.findSettings();
const trimmed = simplexNotificationLink.trim();
const linkChanged = settings.simplexNotificationLink !== trimmed;
await this.shopSettingsRepo.update(settings.id, {
simplexNotificationLink: trimmed,
...(linkChanged ? { simplexNotificationContactId: null } : {})
});
}
private async setSimplexNotificationContactId(contactId: number | null): Promise<void> {
const settings = await this.findSettings();
await this.shopSettingsRepo.update(settings.id, {
simplexNotificationContactId: contactId
});
}
private toView({
id,
logoStorageKey,
faviconStorageKey,
simplexLink,
simplexNotificationLink,
shippingNote,
notificationsEnabled,
notifyOnNewOrder,
notifyOnOrderMessage,
simplexNotificationContactId,
createdAt,
updatedAt
}: ShopSettings): ShopSettingsView {
const setupChecklist = this.buildSetupChecklist({ logoStorageKey, faviconStorageKey, simplexLink, shippingNote });
const { shopName, shopFiatCurrency, monero } = this.configService.get('shopSettings') as Config['shopSettings'];
const logoUrl = logoStorageKey ? getShopBrandingPublicUrl(logoStorageKey) : null;
const faviconUrl = faviconStorageKey ? getShopBrandingPublicUrl(faviconStorageKey) : null;
const isSetupComplete = this.isSetupComplete(setupChecklist);
return {
id,
shopName,
shopFiatCurrency,
monero,
logoUrl,
faviconUrl,
simplexLink,
simplexNotificationLink,
shippingNote,
notificationsEnabled,
notifyOnNewOrder,
notifyOnOrderMessage,
simplexNotificationConnected: simplexNotificationContactId !== null,
isSetupComplete,
setupChecklist,
createdAt,
updatedAt
};
}
private buildSetupChecklist({
logoStorageKey,
faviconStorageKey,
simplexLink,
shippingNote
}: Pick<ShopSettings, 'logoStorageKey' | 'faviconStorageKey' | 'simplexLink' | 'shippingNote'>): SetupChecklist {
const {
validation: { shippingNoteMinLength }
} = this.configService.get('app') as Config['app'];
return {
logo: logoStorageKey !== null,
favicon: faviconStorageKey !== null,
simplexLink: (simplexLink?.length ?? 0) > 0,
shippingNote: (shippingNote?.length ?? 0) >= shippingNoteMinLength
};
}
private isSetupComplete(checklist: SetupChecklist): boolean {
return checklist.logo && checklist.favicon && checklist.simplexLink && checklist.shippingNote;
}
}
@@ -0,0 +1,6 @@
export interface SetupChecklist {
logo: boolean;
favicon: boolean;
simplexLink: boolean;
shippingNote: boolean;
}
@@ -0,0 +1,5 @@
import { MoneroConfirmationTier } from '../../../types/MoneroConfirmationTier';
export interface ShopSettingsMoneroView {
confirmationTiers: MoneroConfirmationTier[];
}
@@ -0,0 +1,23 @@
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
import { SetupChecklist } from './SetupChecklist';
import { ShopSettingsMoneroView } from './ShopSettingsMoneroView';
export interface ShopSettingsView {
id: string | null;
shopName: string;
shopFiatCurrency: ShopFiatCurrency;
monero: ShopSettingsMoneroView;
logoUrl: string | null;
faviconUrl: string | null;
simplexLink: string | null;
simplexNotificationLink: string | null;
shippingNote: string | null;
notificationsEnabled: boolean;
notifyOnNewOrder: boolean;
notifyOnOrderMessage: boolean;
simplexNotificationConnected: boolean;
isSetupComplete: boolean;
setupChecklist: SetupChecklist;
createdAt: Date | null;
updatedAt: Date | null;
}
@@ -0,0 +1,6 @@
export interface StorefrontBranding {
logoUrl: string | null;
faviconUrl: string | null;
simplexLink: string | null;
shippingNote: string | null;
}