import { BadRequestException, ForbiddenException, Injectable, NotFoundException, Optional, ServiceUnavailableException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; import { IsNull, Repository } from 'typeorm'; import { AuditLogService } from '../audit/audit-log.service'; import { ListTemplateEntity } from '../list-templates/list-template.entity'; import { ListTemplateItemEntity } from '../list-templates/list-template-item.entity'; import { ListTemplate, ListTemplateKind, UserList, UserListAccessRole, UserListItem, } from '../list-templates/list-template.types'; import { UserEntity } from '../auth/user.entity'; import { CreateListFromTemplateDto } from '../list-templates/dto/create-list-from-template.dto'; import { AddListItemDto, UpdateListItemDto } from './dto/list-item.dto'; import { CreateListDto } from './dto/create-list.dto'; import { ListRealtimeService } from './list-realtime.service'; import { ShareListDto } from './dto/share-list.dto'; import { UpdateListDto } from './dto/update-list.dto'; import { UserListEntity } from './user-list.entity'; import { UserListItemEntity } from './user-list-item.entity'; import { UserListShareEntity } from './user-list-share.entity'; export interface ListItemSuggestion { title: string; notes?: string; quantity?: number; required: boolean; } export interface ListItemSuggestionsResponse { suggestions: ListItemSuggestion[]; } export interface CreateListWithItemSuggestionsResponse { list: UserList; suggestions: ListItemSuggestion[]; } interface MistralAgentCompletionResponse { choices?: Array<{ message?: { content?: string | null; }; messages?: Array<{ role?: string; content?: string | null | unknown[]; }>; }>; } @Injectable() export class ListsService { private readonly itemSuggestionsEndpoint = 'https://api.mistral.ai/v1/agents/completions'; constructor( @InjectRepository(UserListEntity) private readonly listsRepository: Repository, @InjectRepository(UserListItemEntity) private readonly listItemsRepository: Repository, @InjectRepository(UserListShareEntity) private readonly listSharesRepository: Repository, @InjectRepository(UserEntity) private readonly usersRepository: Repository, @Optional() private readonly auditLogService?: AuditLogService, @Optional() private readonly listRealtimeService?: ListRealtimeService, @Optional() @InjectRepository(ListTemplateEntity) private readonly templatesRepository?: Repository, @Optional() @InjectRepository(ListTemplateItemEntity) private readonly templateItemsRepository?: Repository, ) {} async createList(ownerId: string, createDto: CreateListDto): Promise { const list = this.listsRepository.create({ id: randomUUID(), ownerId, name: this.requireName(createDto.name), description: this.normalizeOptionalText(createDto.description), kind: this.normalizeKind(createDto.kind), deletedAt: null, items: [], }); const savedList = await this.listsRepository.save(list); await this.auditLogService?.record({ actorUserId: ownerId, action: 'list.created', entityType: 'list', entityId: savedList.id, metadata: { name: savedList.name, kind: savedList.kind, }, }); const userList = await this.getList(ownerId, savedList.id); await this.publishListSnapshot(savedList.id); return userList; } async createListWithItemSuggestions( ownerId: string, createDto: CreateListDto, ): Promise { const list = await this.createList(ownerId, createDto); const listEntity = await this.findAccessibleList(ownerId, list.id); const response = await this.callMistralForItemSuggestions(listEntity); const suggestions = this.normalizeItemSuggestions(response, listEntity.items); return { list, suggestions, }; } async createListFromTemplate( ownerId: string, template: ListTemplate, createDto: CreateListFromTemplateDto = {}, ): Promise { if (template.ownerId !== ownerId) { throw new ForbiddenException('List template belongs to another user.'); } const list = this.listsRepository.create({ id: randomUUID(), ownerId, sourceTemplateId: template.id, name: this.normalizeDerivedListName(createDto.name, template.name), description: createDto.description !== undefined ? this.normalizeOptionalText(createDto.description) : template.description, kind: template.kind, deletedAt: null, items: template.items.map((item) => this.listItemsRepository.create({ id: randomUUID(), sourceTemplateItemId: item.id, title: item.title, notes: item.notes, quantity: item.quantity, required: item.required, checked: false, position: item.position, }), ), }); const savedList = await this.listsRepository.save(list); await this.auditLogService?.record({ actorUserId: ownerId, action: 'list.created_from_template', entityType: 'list', entityId: savedList.id, metadata: { name: savedList.name, kind: savedList.kind, sourceTemplateId: template.id, sourceTemplateName: template.name, itemCount: savedList.items?.length ?? 0, }, }); const userList = await this.getList(ownerId, savedList.id); await this.publishListSnapshot(savedList.id); return userList; } async listLists(ownerId: string): Promise { const ownedLists = await this.listsRepository.find({ where: { ownerId, deletedAt: IsNull() }, relations: { items: true, owner: true, shares: { user: true } }, order: { name: 'ASC', items: { position: 'ASC' } }, }); const sharedListShares = await this.listSharesRepository.find({ where: { userId: ownerId }, relations: { list: { items: true, owner: true, shares: { user: true } } }, }); const listsById = new Map(); for (const list of ownedLists) { await this.hydrateListAccessRelations(list); listsById.set(list.id, list); } for (const share of sharedListShares) { const sharedList = share.list ?? (await this.listsRepository.findOne({ where: { id: share.listId, deletedAt: IsNull() }, relations: { items: true, owner: true, shares: { user: true } }, order: { items: { position: 'ASC' } }, })); if (sharedList && !sharedList.deletedAt) { await this.hydrateListAccessRelations(sharedList); listsById.set(sharedList.id, sharedList); } } return [...listsById.values()] .sort((left, right) => left.name.localeCompare(right.name)) .map((list) => this.toUserList(list, ownerId)); } async getList(ownerId: string, listId: string): Promise { return this.toUserList(await this.findAccessibleList(ownerId, listId), ownerId); } async updateList( ownerId: string, listId: string, updateDto: UpdateListDto, ): Promise { const list = await this.findAccessibleList(ownerId, listId); if (updateDto.name !== undefined) { list.name = this.requireName(updateDto.name); } if (updateDto.description !== undefined) { list.description = this.normalizeOptionalText(updateDto.description); } if (updateDto.kind !== undefined) { list.kind = this.normalizeKind(updateDto.kind); } const savedList = await this.listsRepository.save(list); await this.auditLogService?.record({ actorUserId: ownerId, action: 'list.updated', entityType: 'list', entityId: savedList.id, metadata: { changedFields: Object.keys(updateDto).filter( (field) => updateDto[field as keyof UpdateListDto] !== undefined, ), name: savedList.name, kind: savedList.kind, }, }); const userList = await this.getList(ownerId, savedList.id); await this.publishListSnapshot(savedList.id); return userList; } async deleteList(ownerId: string, listId: string): Promise<{ message: string }> { const list = await this.findOwnedList(ownerId, listId); const accessorIds = this.listAccessorIds(list); const metadata = { name: list.name, kind: list.kind, itemCount: list.items.length, }; list.deletedAt = new Date(); await this.listsRepository.save(list); await this.auditLogService?.record({ actorUserId: ownerId, action: 'list.deleted', entityType: 'list', entityId: listId, metadata, }); accessorIds.forEach((accessorId) => this.listRealtimeService?.publishDeleted(accessorId, listId), ); return { message: 'List deleted.' }; } async shareList( ownerId: string, listId: string, shareDto: ShareListDto, ): Promise { const list = await this.findOwnedList(ownerId, listId); const targetUserId = this.requireShareUserId(shareDto.userId); if (targetUserId === ownerId) { throw new BadRequestException('List owner cannot be added as collaborator.'); } const targetUser = await this.usersRepository.findOne({ where: { id: targetUserId }, }); if (!targetUser || !targetUser.verified) { throw new NotFoundException('User was not found.'); } const existingShare = await this.listSharesRepository.findOne({ where: { listId, userId: targetUserId }, }); if (!existingShare) { await this.listSharesRepository.save( this.listSharesRepository.create({ id: randomUUID(), listId, userId: targetUserId, role: 'collaborator', }), ); } await this.auditLogService?.record({ actorUserId: ownerId, action: 'list.shared', entityType: 'list', entityId: list.id, metadata: { sharedWithUserId: targetUserId, sharedWithEmail: targetUser.email, }, }); await this.publishListSnapshot(listId); return this.getList(ownerId, listId); } async removeShare( ownerId: string, listId: string, collaboratorUserId: string, ): Promise { const list = await this.findOwnedList(ownerId, listId); const existingShare = await this.listSharesRepository.findOne({ where: { listId, userId: collaboratorUserId }, }); if (!existingShare) { throw new NotFoundException('List share was not found.'); } await this.listSharesRepository.remove(existingShare); await this.auditLogService?.record({ actorUserId: ownerId, action: 'list.unshared', entityType: 'list', entityId: list.id, metadata: { removedUserId: collaboratorUserId }, }); this.listRealtimeService?.publishDeleted(collaboratorUserId, listId); await this.publishListSnapshot(listId); return this.getList(ownerId, listId); } async addItem( ownerId: string, listId: string, addDto: AddListItemDto, ): Promise { const list = await this.findAccessibleList(ownerId, listId); const item = this.createListItem(addDto, list.items.length); item.listId = list.id; const savedItem = await this.listItemsRepository.save(item); list.items.push(savedItem); await this.listsRepository.save(list); await this.auditLogService?.record({ actorUserId: ownerId, action: 'list.item_created', entityType: 'list_item', entityId: savedItem.id, metadata: { listId, title: savedItem.title, required: savedItem.required, position: savedItem.position, }, }); const updatedList = await this.getList(ownerId, listId); await this.publishListSnapshot(listId); return updatedList; } async updateItem( ownerId: string, listId: string, itemId: string, updateDto: UpdateListItemDto, actorName?: string, ): Promise { const list = await this.findAccessibleList(ownerId, listId); const item = this.findListItem(list, itemId); const wasChecked = item.checked; if (updateDto.title !== undefined) { item.title = this.requireItemTitle(updateDto.title); } if (updateDto.notes !== undefined) { item.notes = this.normalizeOptionalText(updateDto.notes); } if (updateDto.quantity !== undefined) { item.quantity = this.normalizeQuantity(updateDto.quantity); } if (updateDto.required !== undefined) { item.required = this.normalizeBoolean(updateDto.required, 'required'); } if (updateDto.checked !== undefined) { item.checked = this.normalizeBoolean(updateDto.checked, 'checked'); if (item.checked) { item.checkedAt = new Date(); item.checkedByUserId = ownerId; item.checkedByName = actorName ?? ownerId; } else { item.checkedAt = null; item.checkedByUserId = null; item.checkedByName = null; } } await this.listItemsRepository.save(item); await this.listsRepository.save(list); await this.auditLogService?.record({ actorUserId: ownerId, action: updateDto.checked === true ? 'list.item_checked' : updateDto.checked === false ? 'list.item_unchecked' : 'list.item_updated', entityType: 'list_item', entityId: item.id, metadata: { listId, title: item.title, changedFields: Object.keys(updateDto).filter( (field) => updateDto[field as keyof UpdateListItemDto] !== undefined, ), previousChecked: wasChecked, checked: item.checked, checkedAt: item.checkedAt, checkedByName: item.checkedByName, }, }); const updatedList = await this.getList(ownerId, listId); await this.publishListSnapshot(listId); return updatedList; } async deleteItem( ownerId: string, listId: string, itemId: string, ): Promise { const list = await this.findAccessibleList(ownerId, listId); const itemIndex = list.items.findIndex((item) => item.id === itemId); if (itemIndex === -1) { throw new NotFoundException('List item was not found.'); } const [itemToDelete] = list.items.splice(itemIndex, 1); await this.listItemsRepository.remove(itemToDelete); list.items.forEach((item, index) => { item.position = index; }); await this.listItemsRepository.save(list.items); await this.listsRepository.save(list); await this.auditLogService?.record({ actorUserId: ownerId, action: 'list.item_deleted', entityType: 'list_item', entityId: itemId, metadata: { listId, title: itemToDelete.title, position: itemToDelete.position, }, }); const updatedList = await this.getList(ownerId, listId); await this.publishListSnapshot(listId); return updatedList; } async suggestItems( ownerId: string, listId: string, ): Promise { const list = await this.findAccessibleList(ownerId, listId); const response = await this.callMistralForItemSuggestions(list); const suggestions = this.normalizeItemSuggestions(response, list.items); return { suggestions }; } async createTemplateFromList( ownerId: string, listId: string, ): Promise { if (!this.templatesRepository || !this.templateItemsRepository) { throw new ServiceUnavailableException( 'List template storage is not configured.', ); } const list = await this.findAccessibleList(ownerId, listId); const template = this.templatesRepository.create({ id: randomUUID(), ownerId, name: list.name, description: list.description, kind: list.kind, deletedAt: null, items: list.items .sort((left, right) => left.position - right.position) .map((item, index) => this.templateItemsRepository!.create({ id: randomUUID(), title: item.title, notes: item.notes, quantity: item.quantity, required: item.required, position: index, }), ), }); const savedTemplate = await this.templatesRepository.save(template); await this.auditLogService?.record({ actorUserId: ownerId, action: 'template.created_from_list', entityType: 'template', entityId: savedTemplate.id, metadata: { sourceListId: list.id, sourceListName: list.name, name: savedTemplate.name, kind: savedTemplate.kind, itemCount: savedTemplate.items?.length ?? 0, }, }); return this.toListTemplate(savedTemplate); } private async findAccessibleList( ownerId: string, listId: string, ): Promise { const list = await this.listsRepository.findOne({ where: { id: listId, deletedAt: IsNull() }, relations: { items: true, owner: true, shares: { user: true } }, order: { items: { position: 'ASC' } }, }); if (!list) { throw new NotFoundException('List was not found.'); } await this.hydrateListAccessRelations(list); if (!this.canAccessList(list, ownerId)) { throw new ForbiddenException('List belongs to another user.'); } list.items = list.items ?? []; list.shares = list.shares ?? []; return list; } private async findOwnedList( ownerId: string, listId: string, ): Promise { const list = await this.findAccessibleList(ownerId, listId); if (list.ownerId !== ownerId) { throw new ForbiddenException('Only the list owner can perform this action.'); } return list; } private findListItem( list: UserListEntity, itemId: string, ): UserListItemEntity { const item = list.items.find((listItem) => listItem.id === itemId); if (!item) { throw new NotFoundException('List item was not found.'); } return item; } private createListItem( itemDto: AddListItemDto, position: number, ): UserListItemEntity { return this.listItemsRepository.create({ id: randomUUID(), title: this.requireItemTitle(itemDto.title), notes: this.normalizeOptionalText(itemDto.notes), quantity: this.normalizeQuantity(itemDto.quantity), required: itemDto.required === undefined ? true : this.normalizeBoolean(itemDto.required, 'required'), checked: false, position, }); } private normalizeKind(kind?: ListTemplateKind): ListTemplateKind { if (kind === undefined) { return 'custom'; } if ( kind !== 'packing' && kind !== 'shopping' && kind !== 'todo' && kind !== 'custom' ) { throw new BadRequestException('List kind is invalid.'); } return kind; } private requireName(name?: string): string { const normalizedName = name?.trim(); if (!normalizedName) { throw new BadRequestException('List name is required.'); } return normalizedName; } private requireItemTitle(title?: string): string { const normalizedTitle = title?.trim(); if (!normalizedTitle) { throw new BadRequestException('List item title is required.'); } return normalizedTitle; } private normalizeDerivedListName(name: string | undefined, fallback: string) { const normalizedName = name?.trim(); return normalizedName || fallback; } private normalizeOptionalText(value?: string): string | undefined { const normalizedValue = value?.trim(); return normalizedValue || undefined; } private normalizeQuantity(quantity?: number): number | undefined { if (quantity === undefined) { return undefined; } if ( typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity <= 0 ) { throw new BadRequestException('Quantity must be greater than zero.'); } return quantity; } private normalizeBoolean(value: boolean, fieldName: string): boolean { if (typeof value !== 'boolean') { throw new BadRequestException(`${fieldName} must be a boolean.`); } return value; } private requireShareUserId(userId?: string): string { const normalizedUserId = userId?.trim(); if (!normalizedUserId) { throw new BadRequestException('User id is required.'); } return normalizedUserId; } private canAccessList(list: UserListEntity, userId: string): boolean { return ( list.ownerId === userId || Boolean(list.shares?.some((share) => share.userId === userId)) ); } private accessRoleFor( list: UserListEntity, viewerId?: string, ): UserListAccessRole { return list.ownerId === viewerId ? 'owner' : 'collaborator'; } private listAccessorIds(list: UserListEntity): string[] { return [ list.ownerId, ...(list.shares ?? []).map((share) => share.userId), ].filter((userId, index, userIds) => userIds.indexOf(userId) === index); } private async hydrateListAccessRelations(list: UserListEntity): Promise { list.owner ??= (await this.usersRepository.findOne({ where: { id: list.ownerId }, })) ?? undefined; const storedShares = await this.listSharesRepository.find({ where: { listId: list.id }, relations: { user: true }, }); list.shares = storedShares; for (const share of list.shares) { share.user ??= (await this.usersRepository.findOne({ where: { id: share.userId }, })) ?? undefined; } } private async publishListSnapshot(listId: string): Promise { if (!this.listRealtimeService) { return; } const list = await this.listsRepository.findOne({ where: { id: listId, deletedAt: IsNull() }, relations: { items: true, owner: true, shares: { user: true } }, order: { items: { position: 'ASC' } }, }); if (!list) { return; } await this.hydrateListAccessRelations(list); this.listAccessorIds(list).forEach((accessorId) => { this.listRealtimeService?.publishSnapshot( accessorId, this.toUserList(list, accessorId), ); }); } private toUserList(list: UserListEntity, viewerId?: string): UserList { return { id: list.id, ownerId: list.ownerId, ownerName: list.owner?.name ?? undefined, ownerEmail: list.owner?.email ?? undefined, accessRole: this.accessRoleFor(list, viewerId), sourceTemplateId: list.sourceTemplateId ?? undefined, name: list.name, description: list.description ?? undefined, kind: list.kind, items: (list.items ?? []) .sort((left, right) => left.position - right.position) .map((item) => this.toUserListItem(item)), collaborators: (list.shares ?? []) .filter((share) => Boolean(share.user)) .map((share) => ({ id: share.userId, name: share.user?.name ?? undefined, email: share.user!.email, role: 'collaborator', })), createdAt: this.toIsoString(list.createdAt), updatedAt: this.toIsoString(list.updatedAt), }; } private toUserListItem(item: UserListItemEntity): UserListItem { return { id: item.id, sourceTemplateItemId: item.sourceTemplateItemId ?? undefined, title: item.title, notes: item.notes ?? undefined, quantity: item.quantity ?? undefined, required: item.required, checked: item.checked, checkedAt: item.checkedAt ? this.toIsoString(item.checkedAt) : undefined, checkedByUserId: item.checkedByUserId ?? undefined, checkedByName: item.checkedByName ?? undefined, position: item.position, createdAt: this.toIsoString(item.createdAt), updatedAt: this.toIsoString(item.updatedAt), }; } private toListTemplate(template: ListTemplateEntity): ListTemplate { return { id: template.id, ownerId: template.ownerId, name: template.name, description: template.description ?? undefined, kind: template.kind, items: (template.items ?? []) .sort((left, right) => left.position - right.position) .map((item) => ({ id: item.id, title: item.title, notes: item.notes ?? undefined, quantity: item.quantity ?? undefined, required: item.required, position: item.position, createdAt: this.toIsoString(item.createdAt), updatedAt: this.toIsoString(item.updatedAt), })), createdAt: this.toIsoString(template.createdAt), updatedAt: this.toIsoString(template.updatedAt), }; } private toIsoString(value?: Date): string { return (value ?? new Date()).toISOString(); } private async callMistralForItemSuggestions( list: UserListEntity, ): Promise { const apiKey = process.env.MISTRAL_API_KEY; const agentId = process.env.MISTRAL_AGENT_ID; if (!apiKey) { throw new ServiceUnavailableException('Mistral API key is not configured.'); } if (!agentId) { throw new ServiceUnavailableException('Mistral agent id is not configured.'); } const response = await fetch(this.itemSuggestionsEndpoint, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ agent_id: agentId, messages: [ { role: 'system', content: 'Du erzeugst Listify-Item-Vorschlaege. Antworte nur mit JSON im Format {"suggestions":[{"title":"...","notes":"...","quantity":1,"required":true}]}. Keine Markdown-Ausgabe.', }, { role: 'user', content: this.createItemSuggestionPrompt(list), }, ], stream: false, response_format: { type: 'json_object', }, }), }); const responsePayload = await this.readMistralResponsePayload(response); if (!response.ok) { throw new ServiceUnavailableException('Mistral agent request failed.'); } return responsePayload; } private createItemSuggestionPrompt(list: UserListEntity): string { const lines = [ 'Erzeuge bis zu 6 sinnvolle neue Items fuer diese Liste.', 'Schlage keine Items vor, die bereits vorhanden sind.', `Name: ${this.compactText(list.name, 160)}`, `Typ: ${list.kind}`, ]; const description = this.compactText(list.description ?? undefined, 240); if (description) { lines.push(`Beschreibung: ${description}`); } if (list.items.length === 0) { lines.push('Vorhandene Items: keine'); } else { lines.push('Vorhandene Items:'); for (const item of list.items.slice(0, 40)) { const parts = [ `Titel: ${this.compactText(item.title, 160)}`, item.notes ? `Notizen: ${this.compactText(item.notes, 220)}` : null, typeof item.quantity === 'number' ? `Menge: ${item.quantity}` : null, `Pflicht: ${item.required ? 'ja' : 'nein'}`, `Erledigt: ${item.checked ? 'ja' : 'nein'}`, ].filter((part): part is string => part !== null); lines.push(`- ${parts.join(', ')}`); } } return lines.join('\n'); } private async readMistralResponsePayload(response: Response): Promise { const rawBody = await response.text(); if (!rawBody) { return null; } try { return JSON.parse(rawBody) as unknown; } catch { return { rawBody }; } } private normalizeItemSuggestions( responsePayload: unknown, existingItems: UserListItemEntity[], ): ListItemSuggestion[] { const content = this.extractMistralContent(responsePayload); const parsed = this.parseSuggestionsJson(content); const existingTitles = new Set( existingItems.map((item) => this.suggestionKey(item.title)), ); const seenTitles = new Set(); const suggestions: ListItemSuggestion[] = []; for (const value of parsed) { const suggestion = this.normalizeItemSuggestion(value); if (!suggestion) { continue; } const key = this.suggestionKey(suggestion.title); if (existingTitles.has(key) || seenTitles.has(key)) { continue; } seenTitles.add(key); suggestions.push(suggestion); if (suggestions.length === 6) { break; } } return suggestions; } private extractMistralContent(responsePayload: unknown): string | null { if (!responsePayload || typeof responsePayload !== 'object') { return null; } const response = responsePayload as MistralAgentCompletionResponse; const directContent = response.choices?.[0]?.message?.content?.trim(); if (directContent) { return directContent; } for (const choice of response.choices ?? []) { const assistantMessages = (choice.messages ?? []) .filter((message) => message.role === 'assistant') .reverse(); for (const message of assistantMessages) { const content = typeof message.content === 'string' ? message.content.trim() : ''; if (content) { return content; } } } return null; } private parseSuggestionsJson(content: string | null): unknown[] { if (!content) { return []; } try { const parsed = JSON.parse(content) as unknown; if (Array.isArray(parsed)) { return parsed; } if (parsed && typeof parsed === 'object') { const suggestions = (parsed as { suggestions?: unknown }).suggestions; return Array.isArray(suggestions) ? suggestions : []; } } catch { return []; } return []; } private normalizeItemSuggestion(value: unknown): ListItemSuggestion | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; } const candidate = value as { title?: unknown; notes?: unknown; quantity?: unknown; required?: unknown; }; const title = typeof candidate.title === 'string' ? this.compactText(candidate.title, 220) : undefined; if (!title) { return null; } const quantity = typeof candidate.quantity === 'number' && Number.isFinite(candidate.quantity) && candidate.quantity > 0 ? candidate.quantity : undefined; return { title, notes: typeof candidate.notes === 'string' ? this.compactText(candidate.notes, 500) : undefined, quantity, required: typeof candidate.required === 'boolean' ? candidate.required : true, }; } private suggestionKey(value: string): string { return value.trim().replace(/\s+/g, ' ').toLowerCase(); } private compactText(value: string | undefined, maxLength: number): string | undefined { const compacted = value?.replace(/\s+/g, ' ').trim(); if (!compacted) { return undefined; } if (compacted.length <= maxLength) { return compacted; } return `${compacted.slice(0, maxLength - 3)}...`; } }