Add Hardcover shelf sync & unify book mappings

Introduce Hardcover provider support and consolidate per-provider book mapping tables into a unified BookMapping model. Adds two Prisma migrations (add_hardcover_shelves, unify_book_mappings), new backend services (hardcover-api, shelf-sync-core), and provider-specific sync logic and API routes for hardcover shelves with token/list validation. Frontend: new HardcoverForm component, refactor AddShelfModal to support Hardcover, hook updates, and small UI/accessibility tweaks. Also add documentation for Goodreads and Hardcover sync flows and update tests to cover scheduler/prisma helpers.
This commit is contained in:
kikootwo
2026-03-04 10:11:19 -05:00
parent 6ca2e964e8
commit 338331d006
23 changed files with 1613 additions and 1391 deletions
+172
View File
@@ -0,0 +1,172 @@
/**
* Component: Shelf Hook Factory
* Documentation: documentation/frontend/components.md
*
* Generic hook factory for shelf CRUD operations. Each provider (Goodreads,
* Hardcover, etc.) calls this with its API endpoint to get fully typed hooks
* without duplicating the SWR/fetch/mutate boilerplate.
*/
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
export interface ShelfBook {
coverUrl: string;
asin: string | null;
title: string;
author: string;
}
const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json());
/**
* Invalidate both the provider-specific endpoint and the combined /api/user/shelves endpoint.
*/
function revalidate(endpoint: string) {
mutate((key) => typeof key === 'string' && key.includes(endpoint));
mutate((key) => typeof key === 'string' && key.includes('/api/user/shelves'));
}
/**
* Creates a set of hooks for a shelf provider endpoint.
*
* Returns:
* - useList: SWR-based hook to list shelves
* - useAdd: Hook returning { addShelf(body), isLoading, error }
* - useDelete: Hook returning { deleteShelf(id), isLoading, error }
* - useUpdate: Hook returning { updateShelf(id, body), isLoading, error }
*/
export function createShelfHooks<TShelf>(endpoint: string) {
function useList() {
const { accessToken } = useAuth();
const key = accessToken ? endpoint : null;
const { data, error, isLoading } = useSWR(key, fetcher, {
refreshInterval: 30000,
});
return {
shelves: (data?.shelves || []) as TShelf[],
isLoading,
error,
};
}
function useAdd() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const addShelf = async (body: Record<string, unknown>) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to add shelf');
}
revalidate(endpoint);
return data.shelf as TShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { addShelf, isLoading, error };
}
function useDelete() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const deleteShelf = async (shelfId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`${endpoint}/${shelfId}`, {
method: 'DELETE',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to remove shelf');
}
revalidate(endpoint);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { deleteShelf, isLoading, error };
}
function useUpdate() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const updateShelf = async (shelfId: string, body: Record<string, unknown>) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`${endpoint}/${shelfId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to update shelf');
}
revalidate(endpoint);
return data.shelf as TShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { updateShelf, isLoading, error };
}
return { useList, useAdd, useDelete, useUpdate };
}
+10 -140
View File
@@ -5,17 +5,9 @@
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
import { createShelfHooks, ShelfBook } from './createShelfHooks';
export interface ShelfBook {
coverUrl: string;
asin: string | null;
title: string;
author: string;
}
export type { ShelfBook };
export interface GoodreadsShelf {
id: string;
@@ -27,150 +19,28 @@ export interface GoodreadsShelf {
books: ShelfBook[];
}
const fetcher = (url: string) =>
fetchWithAuth(url).then((res) => res.json());
const { useList, useAdd, useDelete, useUpdate } =
createShelfHooks<GoodreadsShelf>('/api/user/goodreads-shelves');
export function useGoodreadsShelves() {
const { accessToken } = useAuth();
const endpoint = accessToken ? '/api/user/goodreads-shelves' : null;
const { data, error, isLoading } = useSWR(
endpoint,
fetcher,
{ refreshInterval: 30000 }
);
return {
shelves: (data?.shelves || []) as GoodreadsShelf[],
isLoading,
error,
};
}
export const useGoodreadsShelves = useList;
export function useAddGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { addShelf: addGeneric, isLoading, error } = useAdd();
const addShelf = async (rssUrl: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth('/api/user/goodreads-shelves', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rssUrl }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to add shelf');
}
// Revalidate shelves list
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
return data.shelf as GoodreadsShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
return addGeneric({ rssUrl });
};
return { addShelf, isLoading, error };
}
export function useDeleteGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const deleteShelf = async (shelfId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/user/goodreads-shelves/${shelfId}`, {
method: 'DELETE',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to remove shelf');
}
// Revalidate shelves list
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { deleteShelf, isLoading, error };
}
export const useDeleteGoodreadsShelf = useDelete;
export function useUpdateGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { updateShelf: updateGeneric, isLoading, error } = useUpdate();
const updateShelf = async (shelfId: string, rssUrl: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(
`/api/user/goodreads-shelves/${shelfId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rssUrl }),
},
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to update shelf');
}
// Revalidate shelves list
mutate(
(key) =>
typeof key === 'string' &&
key.includes('/api/user/goodreads-shelves'),
);
mutate(
(key) => typeof key === 'string' && key.includes('/api/user/shelves'),
);
return data.shelf as GoodreadsShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
return updateGeneric(shelfId, { rssUrl });
};
return { updateShelf, isLoading, error };
+10 -148
View File
@@ -5,17 +5,9 @@
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
import { createShelfHooks, ShelfBook } from './createShelfHooks';
export interface ShelfBook {
coverUrl: string;
asin: string | null;
title: string;
author: string;
}
export type { ShelfBook };
export interface HardcoverShelf {
id: string;
@@ -27,161 +19,31 @@ export interface HardcoverShelf {
books: ShelfBook[];
}
const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json());
const { useList, useAdd, useDelete, useUpdate } =
createShelfHooks<HardcoverShelf>('/api/user/hardcover-shelves');
export function useHardcoverShelves() {
const { accessToken } = useAuth();
const endpoint = accessToken ? '/api/user/hardcover-shelves' : null;
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
refreshInterval: 30000,
});
return {
shelves: (data?.shelves || []) as HardcoverShelf[],
isLoading,
error,
};
}
export const useHardcoverShelves = useList;
export function useAddHardcoverShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { addShelf: addGeneric, isLoading, error } = useAdd();
const addShelf = async (apiToken: string, listId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth('/api/user/hardcover-shelves', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiToken, listId }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to add list');
}
// Revalidate shelves list
mutate(
(key) =>
typeof key === 'string' &&
key.includes('/api/user/hardcover-shelves'),
);
return data.shelf as HardcoverShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
return addGeneric({ apiToken, listId });
};
return { addShelf, isLoading, error };
}
export function useDeleteHardcoverShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const deleteShelf = async (shelfId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(
`/api/user/hardcover-shelves/${shelfId}`,
{
method: 'DELETE',
},
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to remove list');
}
// Revalidate shelves list
mutate(
(key) =>
typeof key === 'string' &&
key.includes('/api/user/hardcover-shelves'),
);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { deleteShelf, isLoading, error };
}
export const useDeleteHardcoverShelf = useDelete;
export function useUpdateHardcoverShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { updateShelf: updateGeneric, isLoading, error } = useUpdate();
const updateShelf = async (
shelfId: string,
updates: { listId?: string; apiToken?: string },
) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(
`/api/user/hardcover-shelves/${shelfId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
},
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to update list');
}
// Revalidate shelves list
mutate(
(key) =>
typeof key === 'string' &&
key.includes('/api/user/hardcover-shelves'),
);
mutate(
(key) => typeof key === 'string' && key.includes('/api/user/shelves'),
);
return data.shelf as HardcoverShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
return updateGeneric(shelfId, updates);
};
return { updateShelf, isLoading, error };