feat(fase-15): fetchSuggestions weight ordering + commonItems store (15.2)

fetchSuggestions now:
  - Orders by `weight DESC, use_count DESC, last_used_at DESC` so admin-
    promoted items lead the dropdown regardless of historical use_count.
  - Accepts `{ excludeNames?: string[]; limit?: number }`. The exclude
    filter is applied as `.not('name', 'in', '(...)')` only when the
    sanitised list is non-empty (PostgREST renders an empty list as a
    SQL error). Each name is lowercased + trimmed to match the
    normalised form `item_frequency.name` is stored in.

commonItems.ts is the unbounded counterpart used by /collective/manage:
loadCommonItems (full catalogue, same sort), setWeight (RPC + optimistic
upsert into the store), purge (RPC + optimistic remove).

Integration test common-items.test.ts (6 specs) covers the role gate
(admin OK, member/guest get P0002), the boost-promotes-low-use-count
ordering, the excludeNames filter, the RPC purge, and the
seed-via-RPC-with-use_count=0 flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 11:18:37 +02:00
parent 5563afd04c
commit 3f96e6cdb3
3 changed files with 326 additions and 14 deletions

View File

@@ -0,0 +1,105 @@
/**
* Common-items store (Fase 15) — the admin curation view of the per-collective
* `item_frequency` table. Unlike `fetchSuggestions` (which is the bounded
* dropdown query on the lists page), this loader is unbounded: the manage
* screen shows the full catalogue so admins can sort, search and act on it.
*
* Writes go through SECURITY DEFINER RPCs that gate on
* `collective_members.role = 'admin'`. The all-deny RLS from migration 006
* is the safety net; the RPCs raise P0002 ("only admins…") for non-admins.
*/
import { writable } from 'svelte/store';
import { getSupabase } from '$lib/supabase';
import type { ItemFrequency } from '@colectivo/types';
export const commonItems = writable<ItemFrequency[]>([]);
export const commonItemsLoading = writable(false);
export async function loadCommonItems(collectiveId: string): Promise<void> {
commonItemsLoading.set(true);
try {
const { data, error } = await getSupabase()
.from('item_frequency')
.select('*')
.eq('collective_id', collectiveId)
.order('weight', { ascending: false })
.order('use_count', { ascending: false })
.order('last_used_at', { ascending: false });
if (!error && data) {
commonItems.set(data as ItemFrequency[]);
}
} finally {
commonItemsLoading.set(false);
}
}
/**
* Upsert weight for a single (collective, name). The RPC normalises name to
* lower(trim(...)) internally; we mirror the same normalisation client-side
* so the optimistic update lands on the right row.
*
* Returns the error (or null) so the caller can surface it as toast/message.
*/
export async function setWeight(
collectiveId: string,
name: string,
weight: number
): Promise<Error | null> {
const normalized = name.toLowerCase().trim();
if (!normalized) return new Error('name must not be empty');
const { error } = await getSupabase().rpc('set_item_frequency_weight', {
p_collective_id: collectiveId,
p_name: normalized,
p_weight: weight
});
if (error) return new Error(error.message);
// Optimistic local update — upsert into the store.
commonItems.update((arr) => {
const idx = arr.findIndex((r) => r.name === normalized);
if (idx >= 0) {
const next = [...arr];
next[idx] = { ...next[idx], weight };
return sortRows(next);
}
const row: ItemFrequency = {
collective_id: collectiveId,
name: normalized,
use_count: 0,
weight,
last_used_at: new Date().toISOString()
};
return sortRows([...arr, row]);
});
return null;
}
/**
* Delete the (collective, name) row from item_frequency. Does NOT touch
* shopping_items — items already living in lists are untouched, but the
* dropdown will stop suggesting this name until someone adds it again (the
* existing trigger will re-create the row with weight=0 then).
*/
export async function purge(collectiveId: string, name: string): Promise<Error | null> {
const normalized = name.toLowerCase().trim();
if (!normalized) return new Error('name must not be empty');
const { error } = await getSupabase().rpc('purge_item_frequency', {
p_collective_id: collectiveId,
p_name: normalized
});
if (error) return new Error(error.message);
commonItems.update((arr) => arr.filter((r) => r.name !== normalized));
return null;
}
function sortRows(arr: ItemFrequency[]): ItemFrequency[] {
return [...arr].sort((a, b) => {
if (b.weight !== a.weight) return b.weight - a.weight;
if (b.use_count !== a.use_count) return b.use_count - a.use_count;
return b.last_used_at.localeCompare(a.last_used_at);
});
}

View File

@@ -274,26 +274,63 @@ export async function reorderItems(items: Pick<ShoppingItem, 'id'>[]) {
// ── Suggestions (item_frequency) ──────────────────────────────────────────────
/**
* Fase 15: ordering switches from pure `use_count DESC` to
* `weight DESC, use_count DESC, last_used_at DESC` — admins can curate the
* top of the dropdown via the new `set_item_frequency_weight` RPC.
*
* `excludeNames` (optional) drops entries already present in the caller's
* working list so the user is not nudged to re-add what they already have.
* Each entry is lowercased + trimmed before being sent (matching the
* normalized form `item_frequency.name` is stored in). When the array is
* empty the .not() filter is skipped entirely — PostgREST renders an empty
* `(...)` list as a SQL error, and the cost of adding the filter for zero
* effect would be wasted bytes on the URL anyway.
*
* `limit` overrides the default (5 without prefix, 10 with). The /collective
* manage view does not call this — it has its own unbounded list query in
* commonItems.ts — but UI experimentation may want a smaller bar.
*/
export interface FetchSuggestionsOptions {
excludeNames?: string[];
limit?: number;
}
export async function fetchSuggestions(
collectiveId: string,
prefix: string
prefix: string,
options: FetchSuggestionsOptions = {}
): Promise<ItemFrequency[]> {
if (!prefix.trim()) {
const { data } = await getSupabase()
.from('item_frequency')
.select('*')
.eq('collective_id', collectiveId)
.order('use_count', { ascending: false })
.limit(5);
return (data as ItemFrequency[]) ?? [];
}
const hasPrefix = !!prefix.trim();
const limit = options.limit ?? (hasPrefix ? 10 : 5);
const { data } = await getSupabase()
let query = getSupabase()
.from('item_frequency')
.select('*')
.eq('collective_id', collectiveId)
.ilike('name', `${prefix.toLowerCase().trim()}%`)
.eq('collective_id', collectiveId);
if (hasPrefix) {
query = query.ilike('name', `${prefix.toLowerCase().trim()}%`);
}
const exclude = (options.excludeNames ?? [])
.map((n) => n.toLowerCase().trim())
.filter((n) => n.length > 0);
if (exclude.length > 0) {
// PostgREST in() takes a comma-separated list wrapped in parens. We
// double-quote each entry to be safe against names containing commas
// or parens (e.g. "rice (white)"); the inner double quotes are escaped
// per PostgREST's filter grammar.
const list = `(${exclude.map((n) => `"${n.replace(/"/g, '\\"')}"`).join(',')})`;
query = query.not('name', 'in', list);
}
const { data } = await query
.order('weight', { ascending: false })
.order('use_count', { ascending: false })
.limit(10);
.order('last_used_at', { ascending: false })
.limit(limit);
return (data as ItemFrequency[]) ?? [];
}