feat: add HelpText model, service, and controller for dynamic help text management
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE `HelpText` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`key` VARCHAR(191) NOT NULL,
|
||||
`scope` VARCHAR(191) NOT NULL DEFAULT 'default',
|
||||
`title` VARCHAR(191) NOT NULL,
|
||||
`content` TEXT NOT NULL,
|
||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `HelpText_key_scope_key`(`key`, `scope`),
|
||||
INDEX `HelpText_key_isActive_idx`(`key`, `isActive`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO `HelpText` (`key`, `scope`, `title`, `content`, `isActive`, `createdAt`, `updatedAt`)
|
||||
VALUES
|
||||
(
|
||||
'receipt_import',
|
||||
'default',
|
||||
'Hjälp: Kvittoimport',
|
||||
'Kvittoimporten hjälper dig att tolka kvitton och lägga till varor i inventarie eller baslager.\n\nSteg:\n1. Ladda upp PDF eller bild.\n2. Granska raderna och justera produkt, mängd och enhet vid behov.\n3. Välj destination (inventarie eller baslager).\n4. Spara markerade rader.\n\nTips:\n- Om en rad är osäker, redigera innan du sparar.\n- Du kan lära in alias för bättre träffar nästa gång.',
|
||||
true,
|
||||
NOW(3),
|
||||
NOW(3)
|
||||
),
|
||||
(
|
||||
'receipt_import',
|
||||
'admin',
|
||||
'Hjälp: Kvittoimport för administratörer',
|
||||
'Kvittoimporten hjälper dig att läsa in kvitton och omvandla rader till produkter i inventarie eller baslager. Som administratör har du utökade möjligheter att förbättra träffsäkerheten för hela systemet.\n\nSå fungerar flödet:\n1. Ladda upp kvitto som PDF eller bild.\n2. Systemet tolkar raderna och föreslår produktmatchning, mängd och enhet.\n3. Granska varje rad innan du sparar.\n4. Välj destination: Inventarie eller Baslager.\n5. Spara valda rader.\n\nMatchning och förslag:\n- Alias-träff: raden matchar mot inlärda alias.\n- Ordbaserad träff: systemet hittar sannolik produkt, men du bör bekräfta.\n- AI-kategoriförslag: visas som stöd när produkt inte matchas direkt.\n\nDet du kan göra per rad:\n- Byta till annan befintlig produkt.\n- Skapa ny produkt om ingen passande finns.\n- Justera mängd, enhet och paketinformation.\n- Välja kategori manuellt vid behov.\n- Markera om alias ska läras in.\n\nAdmin-funktioner i kvittoimport:\n- Du kan spara globala alias som blir fallback för alla användare.\n- Du kan använda privata alias för egna avvikelser.\n- Du kan efter import gå vidare till admin-vyer för att städa dubbletter och kvalitetssäkra data.\n\nRekommenderat arbetssätt:\n- Kontrollera rader med låg säkerhet först.\n- Skapa globala alias bara för stabila och återkommande kvittonamn.\n- Undvik att skapa för många nästan-identiska produkter.',
|
||||
true,
|
||||
NOW(3),
|
||||
NOW(3)
|
||||
);
|
||||
@@ -263,3 +263,17 @@ model UnitMapping {
|
||||
@@index([productId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model HelpText {
|
||||
id Int @id @default(autoincrement())
|
||||
key String
|
||||
scope String @default("default")
|
||||
title String
|
||||
content String @db.Text
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([key, scope])
|
||||
@@index([key, isActive])
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { UserProductsModule } from './user-products/user-products.module';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { AiModule } from './ai/ai.module';
|
||||
import { RealtimeModule } from './realtime/realtime.module';
|
||||
import { HelpTextsModule } from './help-texts/help-texts.module';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
import { RolesGuard } from './auth/roles.guard';
|
||||
|
||||
@@ -46,6 +47,7 @@ import { RolesGuard } from './auth/roles.guard';
|
||||
CategoriesModule,
|
||||
AiModule,
|
||||
RealtimeModule,
|
||||
HelpTextsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpsertHelpTextDto {
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
content!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { UpsertHelpTextDto } from './dto/upsert-help-text.dto';
|
||||
import { HelpTextsService } from './help-texts.service';
|
||||
|
||||
@Controller('help-texts')
|
||||
export class HelpTextsController {
|
||||
constructor(private readonly helpTextsService: HelpTextsService) {}
|
||||
|
||||
@Get(':key')
|
||||
getByKey(
|
||||
@Param('key') key: string,
|
||||
@CurrentUser() user: { role?: string },
|
||||
) {
|
||||
return this.helpTextsService.getResolvedByKey(key, user?.role);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Put(':key/:scope')
|
||||
upsert(
|
||||
@Param('key') key: string,
|
||||
@Param('scope') scope: string,
|
||||
@Body() dto: UpsertHelpTextDto,
|
||||
) {
|
||||
return this.helpTextsService.upsert(key, scope, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { HelpTextsController } from './help-texts.controller';
|
||||
import { HelpTextsService } from './help-texts.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [HelpTextsController],
|
||||
providers: [HelpTextsService],
|
||||
})
|
||||
export class HelpTextsModule {}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UpsertHelpTextDto } from './dto/upsert-help-text.dto';
|
||||
|
||||
type HelpTextScope = 'default' | 'user' | 'admin';
|
||||
|
||||
@Injectable()
|
||||
export class HelpTextsService {
|
||||
private readonly allowedScopes: HelpTextScope[] = ['default', 'user', 'admin'];
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getResolvedByKey(keyRaw: string, roleRaw?: string) {
|
||||
const key = this.normalizeKey(keyRaw);
|
||||
const role = (roleRaw ?? 'user').toLowerCase();
|
||||
const scopePriority: HelpTextScope[] = role === 'admin'
|
||||
? ['admin', 'user', 'default']
|
||||
: ['user', 'default'];
|
||||
|
||||
const rows = await this.prisma.helpText.findMany({
|
||||
where: {
|
||||
key,
|
||||
isActive: true,
|
||||
scope: { in: scopePriority },
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
scope: true,
|
||||
title: true,
|
||||
content: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const scope of scopePriority) {
|
||||
const hit = rows.find((row) => row.scope === scope);
|
||||
if (hit) {
|
||||
return {
|
||||
'key': hit.key,
|
||||
'scope': hit.scope,
|
||||
'title': hit.title,
|
||||
'content': hit.content,
|
||||
'updatedAt': hit.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new NotFoundException(`Ingen aktiv hjälptext hittades för key '${key}'.`);
|
||||
}
|
||||
|
||||
async upsert(keyRaw: string, scopeRaw: string, dto: UpsertHelpTextDto) {
|
||||
const key = this.normalizeKey(keyRaw);
|
||||
const scope = this.normalizeScope(scopeRaw);
|
||||
|
||||
return this.prisma.helpText.upsert({
|
||||
where: {
|
||||
key_scope: { key, scope },
|
||||
},
|
||||
update: {
|
||||
title: dto.title.trim(),
|
||||
content: dto.content.trim(),
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
create: {
|
||||
key,
|
||||
scope,
|
||||
title: dto.title.trim(),
|
||||
content: dto.content.trim(),
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
scope: true,
|
||||
title: true,
|
||||
content: true,
|
||||
isActive: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeKey(value: string): string {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
throw new BadRequestException('Hjälptext-nyckel måste anges.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeScope(value: string): HelpTextScope {
|
||||
const normalized = value.trim().toLowerCase() as HelpTextScope;
|
||||
if (!this.allowedScopes.includes(normalized)) {
|
||||
throw new BadRequestException(
|
||||
`Ogiltig scope '${value}'. Tillåtna scopes: ${this.allowedScopes.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user