1112 lines
31 KiB
TypeScript
1112 lines
31 KiB
TypeScript
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<UserListEntity>,
|
|
@InjectRepository(UserListItemEntity)
|
|
private readonly listItemsRepository: Repository<UserListItemEntity>,
|
|
@InjectRepository(UserListShareEntity)
|
|
private readonly listSharesRepository: Repository<UserListShareEntity>,
|
|
@InjectRepository(UserEntity)
|
|
private readonly usersRepository: Repository<UserEntity>,
|
|
@Optional()
|
|
private readonly auditLogService?: AuditLogService,
|
|
@Optional()
|
|
private readonly listRealtimeService?: ListRealtimeService,
|
|
@Optional()
|
|
@InjectRepository(ListTemplateEntity)
|
|
private readonly templatesRepository?: Repository<ListTemplateEntity>,
|
|
@Optional()
|
|
@InjectRepository(ListTemplateItemEntity)
|
|
private readonly templateItemsRepository?: Repository<ListTemplateItemEntity>,
|
|
) {}
|
|
|
|
async createList(ownerId: string, createDto: CreateListDto): Promise<UserList> {
|
|
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<CreateListWithItemSuggestionsResponse> {
|
|
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<UserList> {
|
|
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<UserList[]> {
|
|
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<string, UserListEntity>();
|
|
|
|
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<UserList> {
|
|
return this.toUserList(await this.findAccessibleList(ownerId, listId), ownerId);
|
|
}
|
|
|
|
async updateList(
|
|
ownerId: string,
|
|
listId: string,
|
|
updateDto: UpdateListDto,
|
|
): Promise<UserList> {
|
|
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<UserList> {
|
|
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<UserList> {
|
|
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<UserList> {
|
|
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<UserList> {
|
|
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<UserList> {
|
|
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<ListItemSuggestionsResponse> {
|
|
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<ListTemplate> {
|
|
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<UserListEntity> {
|
|
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<UserListEntity> {
|
|
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<void> {
|
|
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<void> {
|
|
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<unknown> {
|
|
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<unknown> {
|
|
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<string>();
|
|
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)}...`;
|
|
}
|
|
}
|