chore: fix lint errors — auto-format, isNaN, unused imports, button type
Some checks failed
CI / ci (push) Failing after 1m41s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 22:54:37 +02:00
parent 22f5004e53
commit e044547121
23 changed files with 259 additions and 106 deletions

View File

@@ -36,7 +36,8 @@
"noLabelWithoutControl": "off"
},
"suspicious": {
"noExplicitAny": "off"
"noExplicitAny": "off",
"noArrayIndexKey": "off"
},
"style": {
"noNonNullAssertion": "off"

View File

@@ -25,7 +25,7 @@ const args = Object.fromEntries(
}),
);
const tier = args["tier"] ? Number(args["tier"]) : 1;
const tier = args.tier ? Number(args.tier) : 1;
const dryRun = args["dry-run"] === "true";
async function listActiveManufacturers(targetTier: number) {

View File

@@ -34,7 +34,7 @@ const args = Object.fromEntries(
}),
);
const manufacturerSlug = args["manufacturer"];
const manufacturerSlug = args.manufacturer;
const dryRun = args["dry-run"] === "true";
if (!manufacturerSlug) {

View File

@@ -22,7 +22,11 @@ const [user] = await db
.update(users)
.set({ isAdmin: !revoke })
.where(eq(users.logtoSub, sub))
.returning({ id: users.id, logtoSub: users.logtoSub, isAdmin: users.isAdmin });
.returning({
id: users.id,
logtoSub: users.logtoSub,
isAdmin: users.isAdmin,
});
if (!user) {
console.error(`User not found with logto_sub: ${sub}`);
@@ -30,4 +34,6 @@ if (!user) {
}
const action = revoke ? "Revoked admin from" : "Granted admin to";
console.log(`${action} user ${user.id} (${user.logtoSub}) — isAdmin: ${user.isAdmin}`);
console.log(
`${action} user ${user.id} (${user.logtoSub}) — isAdmin: ${user.isAdmin}`,
);

View File

@@ -244,7 +244,6 @@ export function ComparisonTable({
return (
<ul className="list-disc list-inside space-y-0.5">
{items.map((item, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
<li key={i} className="text-xs text-gray-700">
{item}
</li>
@@ -263,7 +262,6 @@ export function ComparisonTable({
return (
<ul className="list-disc list-inside space-y-0.5">
{items.map((item, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
<li key={i} className="text-xs text-gray-700">
{item}
</li>

View File

@@ -61,9 +61,7 @@ export function ItemCard({
const duplicateItem = useDuplicateItem();
const displayName =
brand && name.startsWith(`${brand} `)
? name.slice(brand.length + 1)
: name;
brand && name.startsWith(`${brand} `) ? name.slice(brand.length + 1) : name;
const handleClick =
linkTo === null

View File

@@ -67,9 +67,7 @@ export function useAdminGlobalItems(query?: string, tagNames?: string[]) {
return useInfiniteQuery({
queryKey: ["admin-global-items", query ?? "", tagNames ?? []],
queryFn: ({ pageParam = 0 }) =>
apiGet<AdminGlobalItemPage>(
`/api/admin/items?offset=${pageParam}&${qs}`,
),
apiGet<AdminGlobalItemPage>(`/api/admin/items?offset=${pageParam}&${qs}`),
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.nextOffset : undefined,
initialPageParam: 0,
@@ -79,8 +77,7 @@ export function useAdminGlobalItems(query?: string, tagNames?: string[]) {
export function useAdminGlobalItem(id: number | null) {
return useQuery({
queryKey: ["admin-global-item", id],
queryFn: () =>
apiGet<AdminGlobalItemDetail>(`/api/admin/items/${id}`),
queryFn: () => apiGet<AdminGlobalItemDetail>(`/api/admin/items/${id}`),
enabled: id != null,
retry: (count, error) =>
error instanceof ApiError && error.status === 404 ? false : count < 3,
@@ -90,13 +87,8 @@ export function useAdminGlobalItem(id: number | null) {
export function useUpdateAdminGlobalItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: UpdateGlobalItemPayload;
}) => apiPut<AdminGlobalItemDetail>(`/api/admin/items/${id}`, data),
mutationFn: ({ id, data }: { id: number; data: UpdateGlobalItemPayload }) =>
apiPut<AdminGlobalItemDetail>(`/api/admin/items/${id}`, data),
onSuccess: (_result, { id }) => {
queryClient.invalidateQueries({ queryKey: ["admin-global-items"] });
queryClient.invalidateQueries({ queryKey: ["admin-global-item", id] });

View File

@@ -2,7 +2,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPost } from "../lib/api";
interface AuthState {
user: { id: string; email?: string; createdAt?: string; isAdmin?: boolean } | null;
user: {
id: string;
email?: string;
createdAt?: string;
isAdmin?: boolean;
} | null;
authenticated: boolean;
}

View File

@@ -22,8 +22,10 @@ import { Route as UsersUserIdRouteImport } from './routes/users/$userId'
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
import { Route as ItemsItemIdRouteImport } from './routes/items/$itemId'
import { Route as GlobalItemsGlobalItemIdRouteImport } from './routes/global-items/$globalItemId'
import { Route as AdminTagsRouteImport } from './routes/admin/tags'
import { Route as AdminItemsRouteImport } from './routes/admin/items'
import { Route as ThreadsThreadIdIndexRouteImport } from './routes/threads/$threadId/index'
import { Route as AdminTagsTagIdRouteImport } from './routes/admin/tags.$tagId'
import { Route as AdminItemsItemIdRouteImport } from './routes/admin/items.$itemId'
import { Route as ThreadsThreadIdCandidatesCandidateIdRouteImport } from './routes/threads/$threadId/candidates/$candidateId'
@@ -92,6 +94,11 @@ const GlobalItemsGlobalItemIdRoute = GlobalItemsGlobalItemIdRouteImport.update({
path: '/global-items/$globalItemId',
getParentRoute: () => rootRouteImport,
} as any)
const AdminTagsRoute = AdminTagsRouteImport.update({
id: '/tags',
path: '/tags',
getParentRoute: () => AdminRoute,
} as any)
const AdminItemsRoute = AdminItemsRouteImport.update({
id: '/items',
path: '/items',
@@ -102,6 +109,11 @@ const ThreadsThreadIdIndexRoute = ThreadsThreadIdIndexRouteImport.update({
path: '/threads/$threadId/',
getParentRoute: () => rootRouteImport,
} as any)
const AdminTagsTagIdRoute = AdminTagsTagIdRouteImport.update({
id: '/$tagId',
path: '/$tagId',
getParentRoute: () => AdminTagsRoute,
} as any)
const AdminItemsItemIdRoute = AdminItemsItemIdRouteImport.update({
id: '/$itemId',
path: '/$itemId',
@@ -121,6 +133,7 @@ export interface FileRoutesByFullPath {
'/profile': typeof ProfileRoute
'/settings': typeof SettingsRoute
'/admin/items': typeof AdminItemsRouteWithChildren
'/admin/tags': typeof AdminTagsRouteWithChildren
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
'/items/$itemId': typeof ItemsItemIdRoute
'/setups/$setupId': typeof SetupsSetupIdRoute
@@ -130,6 +143,7 @@ export interface FileRoutesByFullPath {
'/global-items/': typeof GlobalItemsIndexRoute
'/setups/': typeof SetupsIndexRoute
'/admin/items/$itemId': typeof AdminItemsItemIdRoute
'/admin/tags/$tagId': typeof AdminTagsTagIdRoute
'/threads/$threadId/': typeof ThreadsThreadIdIndexRoute
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
}
@@ -139,6 +153,7 @@ export interface FileRoutesByTo {
'/profile': typeof ProfileRoute
'/settings': typeof SettingsRoute
'/admin/items': typeof AdminItemsRouteWithChildren
'/admin/tags': typeof AdminTagsRouteWithChildren
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
'/items/$itemId': typeof ItemsItemIdRoute
'/setups/$setupId': typeof SetupsSetupIdRoute
@@ -148,6 +163,7 @@ export interface FileRoutesByTo {
'/global-items': typeof GlobalItemsIndexRoute
'/setups': typeof SetupsIndexRoute
'/admin/items/$itemId': typeof AdminItemsItemIdRoute
'/admin/tags/$tagId': typeof AdminTagsTagIdRoute
'/threads/$threadId': typeof ThreadsThreadIdIndexRoute
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
}
@@ -159,6 +175,7 @@ export interface FileRoutesById {
'/profile': typeof ProfileRoute
'/settings': typeof SettingsRoute
'/admin/items': typeof AdminItemsRouteWithChildren
'/admin/tags': typeof AdminTagsRouteWithChildren
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
'/items/$itemId': typeof ItemsItemIdRoute
'/setups/$setupId': typeof SetupsSetupIdRoute
@@ -168,6 +185,7 @@ export interface FileRoutesById {
'/global-items/': typeof GlobalItemsIndexRoute
'/setups/': typeof SetupsIndexRoute
'/admin/items/$itemId': typeof AdminItemsItemIdRoute
'/admin/tags/$tagId': typeof AdminTagsTagIdRoute
'/threads/$threadId/': typeof ThreadsThreadIdIndexRoute
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
}
@@ -180,6 +198,7 @@ export interface FileRouteTypes {
| '/profile'
| '/settings'
| '/admin/items'
| '/admin/tags'
| '/global-items/$globalItemId'
| '/items/$itemId'
| '/setups/$setupId'
@@ -189,6 +208,7 @@ export interface FileRouteTypes {
| '/global-items/'
| '/setups/'
| '/admin/items/$itemId'
| '/admin/tags/$tagId'
| '/threads/$threadId/'
| '/threads/$threadId/candidates/$candidateId'
fileRoutesByTo: FileRoutesByTo
@@ -198,6 +218,7 @@ export interface FileRouteTypes {
| '/profile'
| '/settings'
| '/admin/items'
| '/admin/tags'
| '/global-items/$globalItemId'
| '/items/$itemId'
| '/setups/$setupId'
@@ -207,6 +228,7 @@ export interface FileRouteTypes {
| '/global-items'
| '/setups'
| '/admin/items/$itemId'
| '/admin/tags/$tagId'
| '/threads/$threadId'
| '/threads/$threadId/candidates/$candidateId'
id:
@@ -217,6 +239,7 @@ export interface FileRouteTypes {
| '/profile'
| '/settings'
| '/admin/items'
| '/admin/tags'
| '/global-items/$globalItemId'
| '/items/$itemId'
| '/setups/$setupId'
@@ -226,6 +249,7 @@ export interface FileRouteTypes {
| '/global-items/'
| '/setups/'
| '/admin/items/$itemId'
| '/admin/tags/$tagId'
| '/threads/$threadId/'
| '/threads/$threadId/candidates/$candidateId'
fileRoutesById: FileRoutesById
@@ -340,6 +364,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof GlobalItemsGlobalItemIdRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/tags': {
id: '/admin/tags'
path: '/tags'
fullPath: '/admin/tags'
preLoaderRoute: typeof AdminTagsRouteImport
parentRoute: typeof AdminRoute
}
'/admin/items': {
id: '/admin/items'
path: '/items'
@@ -354,6 +385,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ThreadsThreadIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/tags/$tagId': {
id: '/admin/tags/$tagId'
path: '/$tagId'
fullPath: '/admin/tags/$tagId'
preLoaderRoute: typeof AdminTagsTagIdRouteImport
parentRoute: typeof AdminTagsRoute
}
'/admin/items/$itemId': {
id: '/admin/items/$itemId'
path: '/$itemId'
@@ -383,13 +421,27 @@ const AdminItemsRouteWithChildren = AdminItemsRoute._addFileChildren(
AdminItemsRouteChildren,
)
interface AdminTagsRouteChildren {
AdminTagsTagIdRoute: typeof AdminTagsTagIdRoute
}
const AdminTagsRouteChildren: AdminTagsRouteChildren = {
AdminTagsTagIdRoute: AdminTagsTagIdRoute,
}
const AdminTagsRouteWithChildren = AdminTagsRoute._addFileChildren(
AdminTagsRouteChildren,
)
interface AdminRouteChildren {
AdminItemsRoute: typeof AdminItemsRouteWithChildren
AdminTagsRoute: typeof AdminTagsRouteWithChildren
AdminIndexRoute: typeof AdminIndexRoute
}
const AdminRouteChildren: AdminRouteChildren = {
AdminItemsRoute: AdminItemsRouteWithChildren,
AdminTagsRoute: AdminTagsRouteWithChildren,
AdminIndexRoute: AdminIndexRoute,
}

View File

@@ -1,4 +1,9 @@
import { Link, createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
import {
createFileRoute,
Link,
Outlet,
useNavigate,
} from "@tanstack/react-router";
import { useEffect } from "react";
import { useAuth } from "../hooks/useAuth";
import { LucideIcon } from "../lib/iconData";

View File

@@ -63,7 +63,10 @@ function TagInput({
{tag}
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeTag(tag); }}
onClick={(e) => {
e.stopPropagation();
removeTag(tag);
}}
className="text-gray-400 hover:text-gray-600"
>
×
@@ -76,7 +79,9 @@ function TagInput({
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => { if (inputValue) addTag(inputValue); }}
onBlur={() => {
if (inputValue) addTag(inputValue);
}}
placeholder={value.length === 0 ? "Add tags..." : ""}
className="outline-none bg-transparent text-sm flex-1 min-w-[100px]"
/>
@@ -91,7 +96,11 @@ function AdminItemEditPage() {
const id = Number(itemId);
const navigate = useNavigate();
const { data: item, isLoading, isError } = useAdminGlobalItem(isNaN(id) ? null : id);
const {
data: item,
isLoading,
isError,
} = useAdminGlobalItem(Number.isNaN(id) ? null : id);
const updateMutation = useUpdateAdminGlobalItem();
const deleteMutation = useDeleteAdminGlobalItem();
@@ -121,7 +130,8 @@ function AdminItemEditPage() {
model: item.model,
category: item.category ?? "",
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
priceCents: item.priceCents != null ? String(item.priceCents / 100) : "",
priceCents:
item.priceCents != null ? String(item.priceCents / 100) : "",
imageUrl: item.imageUrl ?? "",
description: item.description ?? "",
sourceUrl: item.sourceUrl ?? "",
@@ -134,7 +144,9 @@ function AdminItemEditPage() {
// Fetch manufacturers for dropdown
useEffect(() => {
apiGet<Manufacturer[]>("/api/manufacturers").then(setManufacturers).catch(() => {});
apiGet<Manufacturer[]>("/api/manufacturers")
.then(setManufacturers)
.catch(() => {});
}, []);
function handleChange(
@@ -146,7 +158,8 @@ function AdminItemEditPage() {
async function handleSave(e: React.FormEvent) {
e.preventDefault();
const weightGrams = form.weightGrams !== "" ? Number(form.weightGrams) : null;
const weightGrams =
form.weightGrams !== "" ? Number(form.weightGrams) : null;
const priceCents =
form.priceCents !== "" ? Math.round(Number(form.priceCents) * 100) : null;
@@ -184,7 +197,10 @@ function AdminItemEditPage() {
<div className="h-4 w-16 bg-gray-100 rounded animate-pulse mb-6" />
<div className="space-y-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-10 bg-gray-100 rounded-lg animate-pulse" />
<div
key={i}
className="h-10 bg-gray-100 rounded-lg animate-pulse"
/>
))}
</div>
</div>
@@ -194,7 +210,9 @@ function AdminItemEditPage() {
if (isError || !item) {
return (
<div className="max-w-2xl mx-auto text-center py-12">
<p className="text-sm text-red-500">Failed to load item. Please try again.</p>
<p className="text-sm text-red-500">
Failed to load item. Please try again.
</p>
</div>
);
}
@@ -252,7 +270,9 @@ function AdminItemEditPage() {
<label className={labelClass}>Brand (Manufacturer)</label>
<select
value={form.manufacturerId}
onChange={(e) => handleChange("manufacturerId", Number(e.target.value))}
onChange={(e) =>
handleChange("manufacturerId", Number(e.target.value))
}
className={`${inputClass} appearance-none bg-white`}
>
<option value={0}>Select manufacturer...</option>

View File

@@ -87,6 +87,7 @@ function AdminItemsPage() {
<div className="flex flex-wrap gap-2 mb-4">
{allTags.map((tag) => (
<button
type="button"
key={tag.id}
onClick={() => toggleTag(tag.name)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
@@ -150,25 +151,36 @@ function AdminItemsPage() {
key={item.id}
className="border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() =>
navigate({ to: "/admin/items/$itemId", params: { itemId: String(item.id) } })
navigate({
to: "/admin/items/$itemId",
params: { itemId: String(item.id) },
})
}
>
<td className="px-4 py-3">
<span className="font-medium text-gray-900">{item.brand}</span>
<span className="font-medium text-gray-900">
{item.brand}
</span>
<span className="text-gray-500 ml-1">{item.model}</span>
</td>
<td className="px-4 py-3 text-gray-700">
{item.category ?? <span className="text-gray-300"></span>}
{item.category ?? (
<span className="text-gray-300"></span>
)}
</td>
<td className="px-4 py-3 text-gray-700">
{item.weightGrams != null
? formatWeight(item.weightGrams)
: <span className="text-gray-300"></span>}
{item.weightGrams != null ? (
formatWeight(item.weightGrams)
) : (
<span className="text-gray-300"></span>
)}
</td>
<td className="px-4 py-3 text-gray-700">
{item.priceCents != null
? formatPrice(item.priceCents)
: <span className="text-gray-300"></span>}
{item.priceCents != null ? (
formatPrice(item.priceCents)
) : (
<span className="text-gray-300"></span>
)}
</td>
<td className="px-4 py-3">
{item.tags.length === 0 ? (
@@ -209,7 +221,9 @@ function AdminItemsPage() {
{/* Empty state (after load, no items) */}
{!isLoading && allItems.length === 0 && !isError && (
<div className="py-12 text-center">
<p className="text-sm font-medium text-gray-900">No items found</p>
<p className="text-sm font-medium text-gray-900">
No items found
</p>
<p className="text-sm text-gray-400 mt-1">
Try a different search or clear your filters.
</p>
@@ -221,7 +235,9 @@ function AdminItemsPage() {
{/* Loading more */}
{isFetchingNextPage && (
<div className="py-4 text-center text-sm text-gray-400">Loading...</div>
<div className="py-4 text-center text-sm text-gray-400">
Loading...
</div>
)}
{/* All loaded message */}

View File

@@ -1,11 +1,11 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import {
type AdminTag,
useAdminTag,
useAdminTags,
useDeleteAdminTag,
useUpdateAdminTag,
type AdminTag,
} from "../../hooks/useAdminTags";
export const Route = createFileRoute("/admin/tags/$tagId")({
@@ -53,12 +53,19 @@ function AdminTagEditPage() {
const id = Number(tagId);
const navigate = useNavigate();
const { data: tag, isLoading, isError } = useAdminTag(isNaN(id) ? null : id);
const {
data: tag,
isLoading,
isError,
} = useAdminTag(Number.isNaN(id) ? null : id);
const { data: allTags } = useAdminTags();
const updateMutation = useUpdateAdminTag();
const deleteMutation = useDeleteAdminTag();
const [form, setForm] = useState({ name: "", parentId: null as number | null });
const [form, setForm] = useState({
name: "",
parentId: null as number | null,
});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => {
@@ -67,7 +74,10 @@ function AdminTagEditPage() {
}
}, [tag]);
function handleChange(field: keyof typeof form, value: string | number | null) {
function handleChange(
field: keyof typeof form,
value: string | number | null,
) {
setForm((prev) => ({ ...prev, [field]: value }));
}
@@ -95,7 +105,10 @@ function AdminTagEditPage() {
<div className="h-4 w-16 bg-gray-100 rounded animate-pulse mb-6" />
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-10 bg-gray-100 rounded-lg animate-pulse" />
<div
key={i}
className="h-10 bg-gray-100 rounded-lg animate-pulse"
/>
))}
</div>
</div>
@@ -105,7 +118,9 @@ function AdminTagEditPage() {
if (isError || !tag) {
return (
<div className="max-w-2xl mx-auto text-center py-12">
<p className="text-sm text-red-500">Failed to load tag. Please try again.</p>
<p className="text-sm text-red-500">
Failed to load tag. Please try again.
</p>
</div>
);
}
@@ -149,7 +164,10 @@ function AdminTagEditPage() {
<select
value={form.parentId ?? ""}
onChange={(e) =>
handleChange("parentId", e.target.value ? Number(e.target.value) : null)
handleChange(
"parentId",
e.target.value ? Number(e.target.value) : null,
)
}
className={`${inputClass} appearance-none bg-white`}
>

View File

@@ -1,9 +1,9 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import {
type AdminTag,
useAdminTags,
useCreateAdminTag,
type AdminTag,
} from "../../hooks/useAdminTags";
import { LucideIcon } from "../../lib/iconData";
@@ -287,7 +287,9 @@ function AdminTagsPage() {
</p>
) : (
<>
<p className="text-sm font-medium text-gray-900">No tags yet</p>
<p className="text-sm font-medium text-gray-900">
No tags yet
</p>
<p className="text-sm text-gray-400 mt-1">
Add your first tag using the form above.
</p>

View File

@@ -202,7 +202,6 @@ function ImportExportSection() {
</p>
)}
{importResult.errors.map((err, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
<p key={i} className="text-red-600">
{err}
</p>

View File

@@ -204,7 +204,9 @@ export const globalItems = pgTable(
export const tags = pgTable("tags", {
id: serial("id").primaryKey(),
name: text("name").notNull().unique(),
parentId: integer("parent_id").references(() => tags.id, { onDelete: "set null" }),
parentId: integer("parent_id").references(() => tags.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
});

View File

@@ -1,5 +1,5 @@
import type { Context, Next } from "hono";
import { eq } from "drizzle-orm";
import type { Context, Next } from "hono";
import { users } from "../../db/schema.ts";
import { getOrCreateUser, verifyApiKey } from "../services/auth.service";
import { getOrCreateUncategorized } from "../services/category.service";

View File

@@ -44,8 +44,8 @@ app.get("/", async (c) => {
const result = await listGlobalItemsForAdmin(db, {
query: q || undefined,
tagNames,
offset: isNaN(offset) ? 0 : offset,
limit: isNaN(limit) || limit > 100 ? 50 : limit,
offset: Number.isNaN(offset) ? 0 : offset,
limit: Number.isNaN(limit) || limit > 100 ? 50 : limit,
});
return c.json(result);
@@ -62,10 +62,7 @@ app.get("/:id", async (c) => {
});
// PUT /api/admin/items/:id — update item fields
app.put(
"/:id",
zValidator("json", updateGlobalItemAdminSchema),
async (c) => {
app.put("/:id", zValidator("json", updateGlobalItemAdminSchema), async (c) => {
const db = c.get("db");
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid item ID" }, 400);
@@ -73,8 +70,7 @@ app.put(
const item = await updateGlobalItemById(db, id, data);
if (!item) return c.json({ error: "Global item not found" }, 404);
return c.json(item);
},
);
});
// DELETE /api/admin/items/:id — delete item with FK cleanup
app.delete("/:id", async (c) => {

View File

@@ -178,7 +178,12 @@ export async function listGlobalItemsForAdmin(
})
.from(globalItemTags)
.innerJoin(tags, eq(tags.id, globalItemTags.tagId))
.where(sql`${globalItemTags.globalItemId} IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`);
.where(
sql`${globalItemTags.globalItemId} IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})`,
);
const tagsByItemId = new Map<number, string[]>();
for (const row of tagRows) {
@@ -194,7 +199,12 @@ export async function listGlobalItemsForAdmin(
ownerCount: count(),
})
.from(items)
.where(sql`${items.globalItemId} IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`)
.where(
sql`${items.globalItemId} IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})`,
)
.groupBy(items.globalItemId);
const ownerCountById = new Map<number, number>();
@@ -241,16 +251,22 @@ export async function updateGlobalItemById(
// Build partial update — only set provided fields
const updateSet: Record<string, unknown> = {};
if (fields.manufacturerId !== undefined) updateSet.manufacturerId = fields.manufacturerId;
if (fields.manufacturerId !== undefined)
updateSet.manufacturerId = fields.manufacturerId;
if (fields.model !== undefined) updateSet.model = fields.model;
if ("category" in fields) updateSet.category = fields.category ?? null;
if ("weightGrams" in fields) updateSet.weightGrams = fields.weightGrams ?? null;
if ("priceCents" in fields) updateSet.priceCents = fields.priceCents ?? null;
if ("weightGrams" in fields)
updateSet.weightGrams = fields.weightGrams ?? null;
if ("priceCents" in fields)
updateSet.priceCents = fields.priceCents ?? null;
if ("imageUrl" in fields) updateSet.imageUrl = fields.imageUrl ?? null;
if ("description" in fields) updateSet.description = fields.description ?? null;
if ("description" in fields)
updateSet.description = fields.description ?? null;
if ("sourceUrl" in fields) updateSet.sourceUrl = fields.sourceUrl ?? null;
if ("imageCredit" in fields) updateSet.imageCredit = fields.imageCredit ?? null;
if ("imageSourceUrl" in fields) updateSet.imageSourceUrl = fields.imageSourceUrl ?? null;
if ("imageCredit" in fields)
updateSet.imageCredit = fields.imageCredit ?? null;
if ("imageSourceUrl" in fields)
updateSet.imageSourceUrl = fields.imageSourceUrl ?? null;
let item: typeof globalItems.$inferSelect | undefined;
if (Object.keys(updateSet).length > 0) {
@@ -295,14 +311,10 @@ export async function deleteGlobalItem(db: Db, id: number) {
.where(eq(items.globalItemId, id));
// 3. Remove tag associations (FK: globalItemTags.globalItemId → globalItems.id, no cascade)
await tx
.delete(globalItemTags)
.where(eq(globalItemTags.globalItemId, id));
await tx.delete(globalItemTags).where(eq(globalItemTags.globalItemId, id));
// 4. Delete the global item
await tx
.delete(globalItems)
.where(eq(globalItems.id, id));
await tx.delete(globalItems).where(eq(globalItems.id, id));
return true;
});

View File

@@ -64,7 +64,11 @@ describe("Admin Tag Routes", () => {
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ id: expect.any(Number), name: "ultralight", parentId: null });
expect(body).toMatchObject({
id: expect.any(Number),
name: "ultralight",
parentId: null,
});
});
it("creates tag with parentId", async () => {

View File

@@ -536,9 +536,15 @@ describe("listGlobalItemsForAdmin", () => {
it("filters by query string (brand/model)", async () => {
const mfr = await insertManufacturer(db, "Salsa", "salsa");
await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Woodsmoke 700" });
await insertGlobalItem(db, {
manufacturerId: mfr.id,
model: "Woodsmoke 700",
});
const mfr2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, { manufacturerId: mfr2.id, model: "Racing Saddle Bag" });
await insertGlobalItem(db, {
manufacturerId: mfr2.id,
model: "Racing Saddle Bag",
});
const result = await listGlobalItemsForAdmin(db, { query: "salsa" });
expect(result.items).toHaveLength(1);
@@ -547,7 +553,10 @@ describe("listGlobalItemsForAdmin", () => {
it("includes tags and ownerCount per item", async () => {
const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Test Item" });
const globalItem = await insertGlobalItem(db, {
manufacturerId: mfr.id,
model: "Test Item",
});
const tag = await insertTag(db, "bikepacking");
await tagGlobalItem(db, globalItem.id, tag.id!);
@@ -556,7 +565,9 @@ describe("listGlobalItemsForAdmin", () => {
.insert(schema.users)
.values({ logtoSub: "test-sub" })
.returning();
await insertItem(db, "My Test Item", user!.id, { globalItemId: globalItem.id });
await insertItem(db, "My Test Item", user!.id, {
globalItemId: globalItem.id,
});
const result = await listGlobalItemsForAdmin(db);
expect(result.items).toHaveLength(1);
@@ -579,7 +590,10 @@ describe("updateGlobalItemById", () => {
it("updates model field by id", async () => {
const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Original" });
const globalItem = await insertGlobalItem(db, {
manufacturerId: mfr.id,
model: "Original",
});
await updateGlobalItemById(db, globalItem.id, { model: "Updated" });
@@ -589,9 +603,14 @@ describe("updateGlobalItemById", () => {
it("syncs tags when tags array provided", async () => {
const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Tagged Item" });
const globalItem = await insertGlobalItem(db, {
manufacturerId: mfr.id,
model: "Tagged Item",
});
await updateGlobalItemById(db, globalItem.id, { tags: ["cycling", "gravel"] });
await updateGlobalItemById(db, globalItem.id, {
tags: ["cycling", "gravel"],
});
const result = await listGlobalItemsForAdmin(db);
const found = result.items.find((i) => i.id === globalItem.id);
@@ -614,7 +633,10 @@ describe("deleteGlobalItem", () => {
it("deletes item and returns true", async () => {
const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "To Delete" });
const globalItem = await insertGlobalItem(db, {
manufacturerId: mfr.id,
model: "To Delete",
});
const result = await deleteGlobalItem(db, globalItem.id);
expect(result).toBe(true);
@@ -625,12 +647,17 @@ describe("deleteGlobalItem", () => {
it("nullifies items.globalItemId before deleting", async () => {
const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Owned Item" });
const globalItem = await insertGlobalItem(db, {
manufacturerId: mfr.id,
model: "Owned Item",
});
const [user] = await db
.insert(schema.users)
.values({ logtoSub: "delete-test-sub" })
.returning();
const userItem = await insertItem(db, "User Item", user!.id, { globalItemId: globalItem.id });
const userItem = await insertItem(db, "User Item", user!.id, {
globalItemId: globalItem.id,
});
await deleteGlobalItem(db, globalItem.id);
@@ -643,7 +670,10 @@ describe("deleteGlobalItem", () => {
it("removes globalItemTags before deleting", async () => {
const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Tagged Delete" });
const globalItem = await insertGlobalItem(db, {
manufacturerId: mfr.id,
model: "Tagged Delete",
});
const tag = await insertTag(db, "delete-tag");
await tagGlobalItem(db, globalItem.id, tag.id!);

View File

@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { manufacturers } from "../../src/db/schema.ts";
import {
createManufacturer,
getManufacturerBySlug,

View File

@@ -5,16 +5,11 @@ import {
deleteTag,
getAdminTags,
getAllTags,
getTagWithCounts,
updateTag,
} from "../../src/server/services/tag.service.ts";
import { createTestDb } from "../helpers/db.ts";
async function insertTag(
db: any,
name: string,
parentId?: number | null,
) {
async function insertTag(db: any, name: string, parentId?: number | null) {
const [row] = await db
.insert(tags)
.values({ name, parentId: parentId ?? null })
@@ -108,7 +103,10 @@ describe("createTag", () => {
it("creates a tag with parentId set to an existing tag id", async () => {
const parent = await createTag(db, { name: "gear" });
const child = await createTag(db, { name: "clothing", parentId: parent.id });
const child = await createTag(db, {
name: "clothing",
parentId: parent.id,
});
expect(child.parentId).toBe(parent.id);
});
});
@@ -175,7 +173,7 @@ describe("deleteTag", () => {
const parent = await insertTag(db, "parent");
const child = await insertTag(db, "child", parent.id);
await deleteTag(db, parent.id);
const [childRow] = await db
const [_childRow] = await db
.select({ parentId: tags.parentId })
.from(tags)
.where((t: any) => t.id === child.id);