feat: add real-time collaborative shopping list at /cospend/list
Real-time shopping list with SSE sync between multiple clients, automatic item categorization using embedding-based classification + Bring icon matching, and card-based UI with category grouping. - SSE broadcast for live sync (add/check/remove items across tabs) - Hybrid categorizer: direct catalog lookup → category-scoped embedding search → per-category default icons, with DB caching - 388 Bring catalog icons matched via multilingual-e5-base embeddings - 170+ English→German icon aliases for reliable cross-language matching - Move cospend dashboard to /cospend/dash, /cospend redirects to list - Shopping icon on homepage links to /cospend/list
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { ShoppingList } from '$models/ShoppingList';
|
||||
import { broadcast } from '$lib/server/shoppingSSE';
|
||||
|
||||
async function getOrCreateList() {
|
||||
let list = await ShoppingList.findOne().lean();
|
||||
if (!list) {
|
||||
list = await ShoppingList.create({ version: 0, items: [] });
|
||||
list = list.toObject();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// GET /api/cospend/list — fetch current shopping list
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const auth = await locals.auth();
|
||||
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
|
||||
|
||||
await dbConnect();
|
||||
const list = await getOrCreateList();
|
||||
return json(list);
|
||||
};
|
||||
|
||||
// PUT /api/cospend/list — update shopping list with version conflict detection
|
||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
const auth = await locals.auth();
|
||||
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
|
||||
|
||||
await dbConnect();
|
||||
const data = await request.json();
|
||||
const { items, expectedVersion } = data;
|
||||
|
||||
if (!Array.isArray(items)) {
|
||||
throw error(400, 'items must be an array');
|
||||
}
|
||||
|
||||
const existing = await getOrCreateList();
|
||||
|
||||
if (expectedVersion != null && existing.version !== expectedVersion) {
|
||||
return json(
|
||||
{ error: 'Version conflict', list: existing },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const newVersion = existing.version + 1;
|
||||
|
||||
const doc = await ShoppingList.findOneAndUpdate(
|
||||
{},
|
||||
{ $set: { items, version: newVersion } },
|
||||
{ upsert: true, returnDocument: 'after', lean: true }
|
||||
);
|
||||
|
||||
broadcast('update', doc);
|
||||
|
||||
return json(doc);
|
||||
};
|
||||
|
||||
// DELETE /api/cospend/list — clear all items
|
||||
export const DELETE: RequestHandler = async ({ locals }) => {
|
||||
const auth = await locals.auth();
|
||||
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
|
||||
|
||||
await dbConnect();
|
||||
const existing = await getOrCreateList();
|
||||
const newVersion = existing.version + 1;
|
||||
|
||||
const doc = await ShoppingList.findOneAndUpdate(
|
||||
{},
|
||||
{ $set: { items: [], version: newVersion } },
|
||||
{ upsert: true, returnDocument: 'after', lean: true }
|
||||
);
|
||||
|
||||
broadcast('update', doc);
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { categorizeItem } from '$lib/server/shoppingCategorizer';
|
||||
|
||||
// POST /api/cospend/list/categorize — categorize a shopping item by name
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const auth = await locals.auth();
|
||||
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
|
||||
|
||||
const { name } = await request.json();
|
||||
if (!name || typeof name !== 'string') {
|
||||
throw error(400, 'name is required');
|
||||
}
|
||||
|
||||
const result = await categorizeItem(name);
|
||||
return json(result);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { addConnection, removeConnection } from '$lib/server/shoppingSSE';
|
||||
|
||||
// GET /api/cospend/list/stream — SSE endpoint for live shopping list updates
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const auth = await locals.auth();
|
||||
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let controllerRef: ReadableStreamDefaultController<Uint8Array>;
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controllerRef = controller;
|
||||
addConnection(controller);
|
||||
|
||||
try {
|
||||
controller.enqueue(encoder.encode(': heartbeat\n\n'));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
removeConnection(controllerRef);
|
||||
}
|
||||
});
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
try {
|
||||
controllerRef.enqueue(encoder.encode(': heartbeat\n\n'));
|
||||
} catch {
|
||||
clearInterval(heartbeatInterval);
|
||||
removeConnection(controllerRef);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user