Files
chat/api/server/controllers/FavoritesController.js
zeekay b2bd7030ad one favourites list, and the two kinds it refused
`vite build` failed on main and had for a while: useSkillFavorites imports
useGetSkillFavoritesQuery and useUpdateSkillFavoritesMutation from ~/data-provider
and neither name has ever existed, so rollup stopped at that import and the client
bundle was never produced. Nor did the /favorites/skills route and the second
MAX_SKILL_FAVORITES cap they were written against exist on the server. The whole
feature was addressed to an API nobody wrote.

A favourited skill is a favourite. It goes in the ONE list the user already has —
the same array, the same /v1/chat/user/settings/favorites route, the same 50 cap —
as `{ skillId }`, a sibling of `{ agentId }` and `{ model, endpoint }`. The read
projects the skill slice out, the write merges it back, so favourites of other
kinds ride through a skill toggle untouched. useGetFavoritesQuery gains a TData
parameter to carry the projection; nothing else changes for its existing callers.

The second kind is `spec`, and that one was a live rejection rather than a build
break. useFavorites has had addFavoriteSpec/toggleFavoriteSpec since before this,
and the controller answered every one of them 400 "Each favorite must have either
agentId or model+endpoint" — a favourited model spec could not be saved. The
schema had no `spec` field either, so mongoose would have dropped it even past the
validator. FavoritesController.spec.js already described the correct validator in
full, down to the refusal messages; the controller was simply an older, narrower
one. Implementing what the spec says takes it from 17 failed / 11 passed to 28 / 28.

Three of those asserted messages now name skillId as well, because the kind list is
written once (KINDS) and every refusal reads it — the spec was written before this
kind existed and could not have listed it.

Client build: green, `client/dist/index.html` present. Gates barrel, credentials
and chrome pass. tsc --noEmit 762 -> 758.
2026-08-13 12:03:11 -07:00

126 lines
4.2 KiB
JavaScript

const { updateUser, getUserById } = require('~/models');
const MAX_FAVORITES = 50;
const MAX_STRING_LENGTH = 256;
/** The kinds a favorite may be, written once so no two refusals disagree. */
const KINDS = 'agentId, skillId, model+endpoint, or spec';
const updateFavoritesController = async (req, res) => {
try {
const { favorites } = req.body;
const userId = req.user.id;
if (!favorites) {
return res.status(400).json({ message: 'Favorites data is required' });
}
if (!Array.isArray(favorites)) {
return res.status(400).json({ message: 'Favorites must be an array' });
}
if (favorites.length > MAX_FAVORITES) {
return res.status(400).json({
code: 'MAX_FAVORITES_EXCEEDED',
message: `Maximum ${MAX_FAVORITES} favorites allowed`,
limit: MAX_FAVORITES,
});
}
for (const fav of favorites) {
for (const field of ['agentId', 'skillId', 'model', 'endpoint']) {
if (fav[field] && fav[field].length > MAX_STRING_LENGTH) {
return res
.status(400)
.json({ message: `${field} exceeds maximum length of ${MAX_STRING_LENGTH}` });
}
}
/* `spec` is the one field with a shape rule of its own: null and undefined
* mean absent, anything else must be a non-empty string. */
if (fav.spec != null && (typeof fav.spec !== 'string' || fav.spec.length === 0)) {
return res.status(400).json({ message: 'spec must be a non-empty string' });
}
if (typeof fav.spec === 'string' && fav.spec.length > MAX_STRING_LENGTH) {
return res
.status(400)
.json({ message: `spec exceeds maximum length of ${MAX_STRING_LENGTH}` });
}
/* One entry names exactly ONE thing. `model` and `endpoint` are halves of
* a single name, so they count once and must both be present. */
const hasAgent = !!fav.agentId;
const hasSkill = !!fav.skillId;
const hasSpec = typeof fav.spec === 'string' && fav.spec.length > 0;
const hasModelPart = !!fav.model || !!fav.endpoint;
const hasModel = !!fav.model && !!fav.endpoint;
const kinds = [hasAgent, hasSkill, hasSpec, hasModel].filter(Boolean).length;
if (kinds > 1) {
return res.status(400).json({ message: `Favorite cannot have multiple types (${KINDS})` });
}
if (hasSpec && (hasAgent || hasSkill || hasModelPart)) {
return res.status(400).json({
message: 'spec cannot be combined with agentId, skillId, model, or endpoint',
});
}
if (hasAgent && hasModelPart) {
return res.status(400).json({
message: 'agentId cannot be combined with model or endpoint',
});
}
if (hasSkill && hasModelPart) {
return res.status(400).json({
message: 'skillId cannot be combined with model or endpoint',
});
}
if (hasModelPart && !hasModel) {
return res.status(400).json({ message: 'model and endpoint must be provided together' });
}
if (kinds === 0) {
return res.status(400).json({ message: `Each favorite must have one of: ${KINDS}` });
}
}
const user = await updateUser(userId, { favorites });
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json(user.favorites);
} catch (error) {
console.error('Error updating favorites:', error);
res.status(500).json({ message: 'Internal server error' });
}
};
const getFavoritesController = async (req, res) => {
try {
if (req.user?.guest === true) {
return res.status(200).json([]);
}
const userId = req.user.id;
const user = await getUserById(userId, 'favorites');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
let favorites = user.favorites || [];
if (!Array.isArray(favorites)) {
favorites = [];
await updateUser(userId, { favorites: [] });
}
res.status(200).json(favorites);
} catch (error) {
console.error('Error fetching favorites:', error);
res.status(500).json({ message: 'Internal server error' });
}
};
module.exports = {
updateFavoritesController,
getFavoritesController,
};