mcp
This commit is contained in:
@@ -137,6 +137,28 @@ export class ListsController {
|
||||
return this.listsService.suggestItems(this.requireUserId(request), listId);
|
||||
}
|
||||
|
||||
@Post(':listId/cleanup-suggestions')
|
||||
suggestCleanup(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('listId') listId: string,
|
||||
) {
|
||||
return this.listsService.suggestListCleanup(
|
||||
this.requireUserId(request),
|
||||
listId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':listId/apply-cleanup')
|
||||
applyCleanup(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('listId') listId: string,
|
||||
) {
|
||||
return this.listsService.applyListCleanup(
|
||||
this.requireUserId(request),
|
||||
listId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':listId/template')
|
||||
createTemplateFromList(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
|
||||
@@ -550,6 +550,184 @@ describe('ListsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('applies AI cleanup suggestions to list details and items', async () => {
|
||||
process.env.MISTRAL_API_KEY = 'test-key';
|
||||
process.env.MISTRAL_MODEL = 'mistral-large-test';
|
||||
const list = await service.createList('user-1', {
|
||||
name: 'sommer urlaub',
|
||||
description: 'zeug fuer reise',
|
||||
kind: 'packing',
|
||||
});
|
||||
const withPass = await service.addItem('user-1', list.id, {
|
||||
title: 'Pass',
|
||||
required: true,
|
||||
});
|
||||
const withDuplicate = await service.addItem('user-1', list.id, {
|
||||
title: 'Reisepass',
|
||||
required: true,
|
||||
});
|
||||
const withSnack = await service.addItem('user-1', list.id, {
|
||||
title: 'snacks',
|
||||
required: true,
|
||||
});
|
||||
const pass = withPass.items[0];
|
||||
const duplicatePass = withDuplicate.items[1];
|
||||
const snack = withSnack.items[2];
|
||||
|
||||
mockMistralResponse({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
listUpdate: {
|
||||
name: 'Sommerurlaub',
|
||||
description: 'Packliste fuer die Sommerreise.',
|
||||
reason: 'Titel und Beschreibung praezisiert.',
|
||||
},
|
||||
itemUpdates: [
|
||||
{
|
||||
itemId: snack.id,
|
||||
title: 'Snacks fuer unterwegs',
|
||||
notes: 'Kleine haltbare Snacks einpacken',
|
||||
required: false,
|
||||
reason: 'Item genauer beschrieben.',
|
||||
},
|
||||
],
|
||||
duplicateDeletions: [
|
||||
{
|
||||
itemId: duplicatePass.id,
|
||||
keptItemId: pass.id,
|
||||
reason: 'Reisepass ist ein Duplikat von Pass.',
|
||||
},
|
||||
],
|
||||
itemAdditions: [
|
||||
{
|
||||
title: 'Tickets',
|
||||
notes: 'Digital und offline verfuegbar halten',
|
||||
required: true,
|
||||
reason: 'Typischer Reisegegenstand fehlt.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await service.applyListCleanup('user-1', list.id);
|
||||
|
||||
expect(response.summary).toEqual({
|
||||
listUpdated: true,
|
||||
itemsUpdated: 1,
|
||||
duplicatesDeleted: 1,
|
||||
itemsAdded: 1,
|
||||
});
|
||||
expect(response.list.name).toBe('Sommerurlaub');
|
||||
expect(response.list.description).toBe('Packliste fuer die Sommerreise.');
|
||||
expect(response.list.items.map((item) => item.title)).toEqual([
|
||||
'Pass',
|
||||
'Snacks fuer unterwegs',
|
||||
'Tickets',
|
||||
]);
|
||||
expect(response.list.items[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
notes: 'Kleine haltbare Snacks einpacken',
|
||||
required: false,
|
||||
}),
|
||||
);
|
||||
expect(response.cleanup.duplicateDeletions).toEqual([
|
||||
expect.objectContaining({
|
||||
itemId: duplicatePass.id,
|
||||
keptItemId: pass.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const requestPayload = getMistralRequestPayload();
|
||||
expect(requestPayload.inputs[0].content).toContain('Name: sommer urlaub');
|
||||
expect(requestPayload.inputs[0].content).toContain(`itemId: ${snack.id}`);
|
||||
expect(requestPayload.instructions).toContain(
|
||||
'Du bereinigst eine Listify-Liste.',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores malformed AI cleanup content without mutating the list', async () => {
|
||||
process.env.MISTRAL_API_KEY = 'test-key';
|
||||
const list = await service.createList('user-1', {
|
||||
name: 'Einkauf',
|
||||
description: 'Lebensmittel',
|
||||
kind: 'shopping',
|
||||
});
|
||||
await service.addItem('user-1', list.id, { title: 'Milch' });
|
||||
mockMistralResponse({
|
||||
choices: [{ message: { content: 'keine json antwort' } }],
|
||||
});
|
||||
|
||||
const response = await service.applyListCleanup('user-1', list.id);
|
||||
|
||||
expect(response.summary).toEqual({
|
||||
listUpdated: false,
|
||||
itemsUpdated: 0,
|
||||
duplicatesDeleted: 0,
|
||||
itemsAdded: 0,
|
||||
});
|
||||
expect(response.list.name).toBe('Einkauf');
|
||||
expect(response.list.description).toBe('Lebensmittel');
|
||||
expect(response.list.items.map((item) => item.title)).toEqual(['Milch']);
|
||||
});
|
||||
|
||||
it('ignores unsafe AI cleanup actions', async () => {
|
||||
process.env.MISTRAL_API_KEY = 'test-key';
|
||||
const list = await service.createList('user-1', {
|
||||
name: 'Todo',
|
||||
kind: 'todo',
|
||||
});
|
||||
const withFirstItem = await service.addItem('user-1', list.id, {
|
||||
title: 'Termin buchen',
|
||||
});
|
||||
const withCheckedItem = await service.updateItem(
|
||||
'user-1',
|
||||
list.id,
|
||||
withFirstItem.items[0].id,
|
||||
{ checked: true },
|
||||
);
|
||||
mockMistralResponse({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
itemUpdates: [
|
||||
{ itemId: 'missing', title: 'Soll ignoriert werden' },
|
||||
],
|
||||
duplicateDeletions: [
|
||||
{
|
||||
itemId: withCheckedItem.items[0].id,
|
||||
keptItemId: 'missing',
|
||||
reason: 'Checked items must not be deleted.',
|
||||
},
|
||||
],
|
||||
itemAdditions: [{ title: 'Termin buchen', required: true }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await service.applyListCleanup('user-1', list.id);
|
||||
|
||||
expect(response.summary).toEqual({
|
||||
listUpdated: false,
|
||||
itemsUpdated: 0,
|
||||
duplicatesDeleted: 0,
|
||||
itemsAdded: 0,
|
||||
});
|
||||
expect(response.list.items).toEqual([
|
||||
expect.objectContaining({
|
||||
title: 'Termin buchen',
|
||||
checked: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty suggestion list for malformed provider content', async () => {
|
||||
process.env.MISTRAL_API_KEY = 'test-key';
|
||||
process.env.MISTRAL_MODEL = 'mistral-large-test';
|
||||
@@ -576,6 +754,9 @@ describe('ListsService', () => {
|
||||
await expect(service.suggestItems('user-1', list.id)).rejects.toThrow(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
await expect(service.applyListCleanup('user-1', list.id)).rejects.toThrow(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,53 @@ export interface CreateListWithItemSuggestionsResponse {
|
||||
suggestions: ListItemSuggestion[];
|
||||
}
|
||||
|
||||
export interface ListCleanupListUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ListCleanupItemUpdate {
|
||||
itemId: string;
|
||||
title?: string;
|
||||
notes?: string;
|
||||
quantity?: number;
|
||||
required?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ListCleanupDuplicateDeletion {
|
||||
itemId: string;
|
||||
keptItemId: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ListCleanupItemAddition extends ListItemSuggestion {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ListCleanupSuggestion {
|
||||
listUpdate?: ListCleanupListUpdate;
|
||||
itemUpdates: ListCleanupItemUpdate[];
|
||||
duplicateDeletions: ListCleanupDuplicateDeletion[];
|
||||
itemAdditions: ListCleanupItemAddition[];
|
||||
}
|
||||
|
||||
export interface ListCleanupSuggestionsResponse {
|
||||
cleanup: ListCleanupSuggestion;
|
||||
}
|
||||
|
||||
export interface ApplyListCleanupResponse {
|
||||
list: UserList;
|
||||
cleanup: ListCleanupSuggestion;
|
||||
summary: {
|
||||
listUpdated: boolean;
|
||||
itemsUpdated: number;
|
||||
duplicatesDeleted: number;
|
||||
itemsAdded: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface MistralCompletionResponse {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
@@ -68,6 +115,8 @@ interface MistralCompletionResponse {
|
||||
export class ListsService {
|
||||
private readonly itemSuggestionsEndpoint =
|
||||
'https://api.mistral.ai/v1/conversations';
|
||||
private readonly listCleanupEndpoint =
|
||||
'https://api.mistral.ai/v1/conversations';
|
||||
private readonly defaultMistralModel = 'mistral-large-latest';
|
||||
|
||||
constructor(
|
||||
@@ -551,6 +600,95 @@ export class ListsService {
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
async suggestListCleanup(
|
||||
ownerId: string,
|
||||
listId: string,
|
||||
): Promise<ListCleanupSuggestionsResponse> {
|
||||
const list = await this.findAccessibleList(ownerId, listId);
|
||||
const response = await this.callMistralForListCleanup(list);
|
||||
|
||||
return {
|
||||
cleanup: this.normalizeListCleanup(response, list),
|
||||
};
|
||||
}
|
||||
|
||||
async applyListCleanup(
|
||||
ownerId: string,
|
||||
listId: string,
|
||||
): Promise<ApplyListCleanupResponse> {
|
||||
const list = await this.findAccessibleList(ownerId, listId);
|
||||
const response = await this.callMistralForListCleanup(list);
|
||||
const cleanup = this.normalizeListCleanup(response, list);
|
||||
const deletedItemIds = new Set(
|
||||
cleanup.duplicateDeletions.map((deletion) => deletion.itemId),
|
||||
);
|
||||
let currentList: UserList | null = null;
|
||||
let listUpdated = false;
|
||||
let itemsUpdated = 0;
|
||||
let duplicatesDeleted = 0;
|
||||
let itemsAdded = 0;
|
||||
|
||||
if (cleanup.listUpdate) {
|
||||
currentList = await this.updateList(ownerId, listId, {
|
||||
...(cleanup.listUpdate.name !== undefined
|
||||
? { name: cleanup.listUpdate.name }
|
||||
: {}),
|
||||
...(cleanup.listUpdate.description !== undefined
|
||||
? { description: cleanup.listUpdate.description }
|
||||
: {}),
|
||||
});
|
||||
listUpdated = true;
|
||||
}
|
||||
|
||||
for (const itemUpdate of cleanup.itemUpdates) {
|
||||
if (deletedItemIds.has(itemUpdate.itemId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
currentList = await this.updateItem(ownerId, listId, itemUpdate.itemId, {
|
||||
...(itemUpdate.title !== undefined ? { title: itemUpdate.title } : {}),
|
||||
...(itemUpdate.notes !== undefined ? { notes: itemUpdate.notes } : {}),
|
||||
...(itemUpdate.quantity !== undefined
|
||||
? { quantity: itemUpdate.quantity }
|
||||
: {}),
|
||||
...(itemUpdate.required !== undefined
|
||||
? { required: itemUpdate.required }
|
||||
: {}),
|
||||
});
|
||||
itemsUpdated += 1;
|
||||
}
|
||||
|
||||
for (const duplicateDeletion of cleanup.duplicateDeletions) {
|
||||
currentList = await this.deleteItem(
|
||||
ownerId,
|
||||
listId,
|
||||
duplicateDeletion.itemId,
|
||||
);
|
||||
duplicatesDeleted += 1;
|
||||
}
|
||||
|
||||
for (const itemAddition of cleanup.itemAdditions) {
|
||||
currentList = await this.addItem(ownerId, listId, {
|
||||
title: itemAddition.title,
|
||||
notes: itemAddition.notes,
|
||||
quantity: itemAddition.quantity,
|
||||
required: itemAddition.required,
|
||||
});
|
||||
itemsAdded += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
list: currentList ?? (await this.getList(ownerId, listId)),
|
||||
cleanup,
|
||||
summary: {
|
||||
listUpdated,
|
||||
itemsUpdated,
|
||||
duplicatesDeleted,
|
||||
itemsAdded,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createTemplateFromList(
|
||||
ownerId: string,
|
||||
listId: string,
|
||||
@@ -975,6 +1113,48 @@ export class ListsService {
|
||||
return responsePayload;
|
||||
}
|
||||
|
||||
private async callMistralForListCleanup(
|
||||
list: UserListEntity,
|
||||
): Promise<unknown> {
|
||||
const apiKey = process.env.MISTRAL_API_KEY;
|
||||
const model = process.env.MISTRAL_MODEL ?? this.defaultMistralModel;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Mistral API key is not configured.',
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(this.listCleanupEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
inputs: [
|
||||
{
|
||||
role: 'user',
|
||||
content: this.createListCleanupPrompt(list),
|
||||
},
|
||||
],
|
||||
instructions:
|
||||
'Du bereinigst eine Listify-Liste. Antworte nur mit JSON im Format {"listUpdate":{"name":"...","description":"...","reason":"..."},"itemUpdates":[{"itemId":"...","title":"...","notes":"...","quantity":1,"required":true,"reason":"..."}],"duplicateDeletions":[{"itemId":"...","keptItemId":"...","reason":"..."}],"itemAdditions":[{"title":"...","notes":"...","quantity":1,"required":true,"reason":"..."}]}. Keine Markdown-Ausgabe. Nutze ausschliesslich vorhandene itemId-Werte fuer itemUpdates und duplicateDeletions.',
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
const responsePayload = await this.readMistralResponsePayload(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Mistral conversation request failed.',
|
||||
);
|
||||
}
|
||||
|
||||
return responsePayload;
|
||||
}
|
||||
|
||||
private createItemSuggestionPrompt(list: UserListEntity): string {
|
||||
const lines = [
|
||||
'Erzeuge bis zu 6 sinnvolle neue Items fuer diese Liste.',
|
||||
@@ -1010,6 +1190,40 @@ export class ListsService {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private createListCleanupPrompt(list: UserListEntity): string {
|
||||
const lines = [
|
||||
'Analysiere diese Liste und schlage konkrete Bereinigungen vor.',
|
||||
'Korrigiere Rechtschreibung und unklare Formulierungen im Listentitel, in der Beschreibung und in Items.',
|
||||
'Erkenne doppelte Items und markiere nur das schlechtere Duplikat zum Loeschen.',
|
||||
'Schlage fehlende sinnvolle Items vor, wenn sie die Liste praktisch ergaenzen.',
|
||||
'Aendere keine Bedeutung und loesche keine erledigten Items.',
|
||||
`Name: ${this.compactText(list.name, 160)}`,
|
||||
`Typ: ${list.kind}`,
|
||||
`Beschreibung: ${this.compactText(list.description ?? undefined, 500) ?? 'keine'}`,
|
||||
];
|
||||
|
||||
if (list.items.length === 0) {
|
||||
lines.push('Vorhandene Items: keine');
|
||||
} else {
|
||||
lines.push('Vorhandene Items:');
|
||||
|
||||
for (const item of list.items.slice(0, 80)) {
|
||||
const parts = [
|
||||
`itemId: ${item.id}`,
|
||||
`Titel: ${this.compactText(item.title, 220)}`,
|
||||
item.notes ? `Notizen: ${this.compactText(item.notes, 500)}` : 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> {
|
||||
@@ -1172,6 +1386,303 @@ export class ListsService {
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeListCleanup(
|
||||
responsePayload: unknown,
|
||||
list: UserListEntity,
|
||||
): ListCleanupSuggestion {
|
||||
const content = this.extractMistralContent(responsePayload);
|
||||
const parsed = this.parseObjectJson(content);
|
||||
const listUpdate = this.normalizeCleanupListUpdate(
|
||||
parsed?.listUpdate,
|
||||
list,
|
||||
);
|
||||
const duplicateDeletions = this.normalizeCleanupDuplicateDeletions(
|
||||
parsed?.duplicateDeletions,
|
||||
list,
|
||||
);
|
||||
const deletedItemIds = new Set(
|
||||
duplicateDeletions.map((deletion) => deletion.itemId),
|
||||
);
|
||||
const itemUpdates = this.normalizeCleanupItemUpdates(
|
||||
parsed?.itemUpdates,
|
||||
list,
|
||||
).filter((update) => !deletedItemIds.has(update.itemId));
|
||||
|
||||
return {
|
||||
...(listUpdate ? { listUpdate } : {}),
|
||||
itemUpdates,
|
||||
duplicateDeletions,
|
||||
itemAdditions: this.normalizeCleanupItemAdditions(
|
||||
parsed?.itemAdditions,
|
||||
list,
|
||||
itemUpdates,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private parseObjectJson(
|
||||
content: string | null,
|
||||
): Record<string, unknown> | null {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeCleanupListUpdate(
|
||||
value: unknown,
|
||||
list: UserListEntity,
|
||||
): ListCleanupListUpdate | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const candidate = value as {
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
reason?: unknown;
|
||||
};
|
||||
const name =
|
||||
typeof candidate.name === 'string'
|
||||
? this.compactText(candidate.name, 160)
|
||||
: undefined;
|
||||
const description =
|
||||
typeof candidate.description === 'string'
|
||||
? this.compactText(candidate.description, 500)
|
||||
: undefined;
|
||||
const reason =
|
||||
typeof candidate.reason === 'string'
|
||||
? this.compactText(candidate.reason, 360)
|
||||
: undefined;
|
||||
const update: ListCleanupListUpdate = {
|
||||
...(name && name !== list.name ? { name } : {}),
|
||||
...(description && description !== (list.description ?? '')
|
||||
? { description }
|
||||
: {}),
|
||||
...(reason ? { reason } : {}),
|
||||
};
|
||||
|
||||
return update.name || update.description ? update : undefined;
|
||||
}
|
||||
|
||||
private normalizeCleanupItemUpdates(
|
||||
value: unknown,
|
||||
list: UserListEntity,
|
||||
): ListCleanupItemUpdate[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemsById = new Map(list.items.map((item) => [item.id, item]));
|
||||
const updates: ListCleanupItemUpdate[] = [];
|
||||
const seenItemIds = new Set<string>();
|
||||
|
||||
for (const itemValue of value) {
|
||||
if (
|
||||
!itemValue ||
|
||||
typeof itemValue !== 'object' ||
|
||||
Array.isArray(itemValue)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = itemValue as {
|
||||
itemId?: unknown;
|
||||
title?: unknown;
|
||||
notes?: unknown;
|
||||
quantity?: unknown;
|
||||
required?: unknown;
|
||||
reason?: unknown;
|
||||
};
|
||||
const itemId = typeof candidate.itemId === 'string' ? candidate.itemId : '';
|
||||
const existingItem = itemsById.get(itemId);
|
||||
|
||||
if (!existingItem || seenItemIds.has(itemId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const title =
|
||||
typeof candidate.title === 'string'
|
||||
? this.compactText(candidate.title, 220)
|
||||
: undefined;
|
||||
const notes =
|
||||
typeof candidate.notes === 'string'
|
||||
? this.compactText(candidate.notes, 500)
|
||||
: undefined;
|
||||
const quantity =
|
||||
typeof candidate.quantity === 'number' &&
|
||||
Number.isFinite(candidate.quantity) &&
|
||||
candidate.quantity > 0
|
||||
? candidate.quantity
|
||||
: undefined;
|
||||
const required =
|
||||
typeof candidate.required === 'boolean'
|
||||
? candidate.required
|
||||
: undefined;
|
||||
const reason =
|
||||
typeof candidate.reason === 'string'
|
||||
? this.compactText(candidate.reason, 360)
|
||||
: undefined;
|
||||
const update: ListCleanupItemUpdate = {
|
||||
itemId,
|
||||
...(title && title !== existingItem.title ? { title } : {}),
|
||||
...(notes !== undefined && notes !== (existingItem.notes ?? '')
|
||||
? { notes }
|
||||
: {}),
|
||||
...(quantity !== undefined && quantity !== existingItem.quantity
|
||||
? { quantity }
|
||||
: {}),
|
||||
...(required !== undefined && required !== existingItem.required
|
||||
? { required }
|
||||
: {}),
|
||||
...(reason ? { reason } : {}),
|
||||
};
|
||||
const hasChange =
|
||||
update.title !== undefined ||
|
||||
update.notes !== undefined ||
|
||||
update.quantity !== undefined ||
|
||||
update.required !== undefined;
|
||||
|
||||
if (!hasChange) {
|
||||
continue;
|
||||
}
|
||||
|
||||
updates.push(update);
|
||||
seenItemIds.add(itemId);
|
||||
|
||||
if (updates.length === 40) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
private normalizeCleanupDuplicateDeletions(
|
||||
value: unknown,
|
||||
list: UserListEntity,
|
||||
): ListCleanupDuplicateDeletion[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemsById = new Map(list.items.map((item) => [item.id, item]));
|
||||
const deletions: ListCleanupDuplicateDeletion[] = [];
|
||||
const seenItemIds = new Set<string>();
|
||||
|
||||
for (const deletionValue of value) {
|
||||
if (
|
||||
!deletionValue ||
|
||||
typeof deletionValue !== 'object' ||
|
||||
Array.isArray(deletionValue)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = deletionValue as {
|
||||
itemId?: unknown;
|
||||
keptItemId?: unknown;
|
||||
reason?: unknown;
|
||||
};
|
||||
const itemId = typeof candidate.itemId === 'string' ? candidate.itemId : '';
|
||||
const keptItemId =
|
||||
typeof candidate.keptItemId === 'string' ? candidate.keptItemId : '';
|
||||
const item = itemsById.get(itemId);
|
||||
|
||||
if (
|
||||
!item ||
|
||||
item.checked ||
|
||||
!itemsById.has(keptItemId) ||
|
||||
itemId === keptItemId ||
|
||||
seenItemIds.has(itemId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
deletions.push({
|
||||
itemId,
|
||||
keptItemId,
|
||||
reason:
|
||||
typeof candidate.reason === 'string'
|
||||
? this.compactText(candidate.reason, 360)
|
||||
: undefined,
|
||||
});
|
||||
seenItemIds.add(itemId);
|
||||
|
||||
if (deletions.length === 40) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return deletions;
|
||||
}
|
||||
|
||||
private normalizeCleanupItemAdditions(
|
||||
value: unknown,
|
||||
list: UserListEntity,
|
||||
itemUpdates: ListCleanupItemUpdate[],
|
||||
): ListCleanupItemAddition[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenTitles = new Set(
|
||||
list.items.map((item) => this.suggestionKey(item.title)),
|
||||
);
|
||||
|
||||
for (const itemUpdate of itemUpdates) {
|
||||
if (itemUpdate.title) {
|
||||
seenTitles.add(this.suggestionKey(itemUpdate.title));
|
||||
}
|
||||
}
|
||||
|
||||
const additions: ListCleanupItemAddition[] = [];
|
||||
|
||||
for (const itemValue of value) {
|
||||
const suggestion = this.normalizeItemSuggestion(itemValue);
|
||||
|
||||
if (!suggestion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = this.suggestionKey(suggestion.title);
|
||||
|
||||
if (seenTitles.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reason =
|
||||
itemValue && typeof itemValue === 'object' && !Array.isArray(itemValue)
|
||||
? this.compactText(
|
||||
typeof (itemValue as { reason?: unknown }).reason === 'string'
|
||||
? ((itemValue as { reason?: unknown }).reason as string)
|
||||
: undefined,
|
||||
360,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
additions.push({
|
||||
...suggestion,
|
||||
...(reason ? { reason } : {}),
|
||||
});
|
||||
seenTitles.add(key);
|
||||
|
||||
if (additions.length === 10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return additions;
|
||||
}
|
||||
|
||||
private suggestionKey(value: string): string {
|
||||
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user