diff --git a/src/config/routes.ts b/src/config/routes.ts index 008cc52..1c82744 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -17,10 +17,10 @@ export const MERGE_PERSON_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/people/ export const EXIF_DISTRIBUTION_PATH = (column: string) => BASE_API_ENDPOINT + "/analytics/exif/" + column; // Albums +// Potential-albums grouping (day vs trip) and asset-detail (single day vs +// date range) share one endpoint each, distinguished by a `groupBy` param. export const LIST_POTENTIAL_ALBUMS_DATES_PATH = BASE_API_ENDPOINT + "/albums/potential-albums-dates"; export const LIST_POTENTIAL_ALBUMS_ASSETS_PATH = BASE_API_ENDPOINT + "/albums/potential-albums-assets"; -export const LIST_POTENTIAL_TRIPS_PATH = BASE_API_ENDPOINT + "/albums/potential-trips"; -export const LIST_POTENTIAL_TRIP_ASSETS_PATH = BASE_API_ENDPOINT + "/albums/potential-trip-assets"; export const LIST_ALBUMS_PATH = BASE_API_ENDPOINT + "/albums/list"; export const ALBUM_INFO_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/info"; diff --git a/src/handlers/api/album.handler.ts b/src/handlers/api/album.handler.ts index 32d26ec..944d2f5 100644 --- a/src/handlers/api/album.handler.ts +++ b/src/handlers/api/album.handler.ts @@ -1,4 +1,4 @@ -import { ADD_ASSETS_ALBUMS_PATH, ALBUM_ASSETS_PATH, ALBUM_INFO_PATH, ALBUM_PEOPLE_PATH, CREATE_ALBUM_PATH, DELETE_ALBUMS_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH, LIST_POTENTIAL_TRIPS_PATH, LIST_POTENTIAL_TRIP_ASSETS_PATH, MERGE_ALBUMS_PATH, SHARE_ALBUMS_PATH } from "@/config/routes"; +import { ADD_ASSETS_ALBUMS_PATH, ALBUM_ASSETS_PATH, ALBUM_INFO_PATH, ALBUM_PEOPLE_PATH, CREATE_ALBUM_PATH, DELETE_ALBUMS_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH, MERGE_ALBUMS_PATH, SHARE_ALBUMS_PATH } from "@/config/routes"; import { cleanUpAsset } from "@/helpers/asset.helper"; import API from "@/lib/api"; import { IAlbumCreate } from "@/types/album"; @@ -47,7 +47,7 @@ export interface IPotentialTripsResponse { } export const listPotentialTrips = async (filters?: IPotentialTripsFilters): Promise => { - return API.get(LIST_POTENTIAL_TRIPS_PATH, filters); + return API.get(LIST_POTENTIAL_ALBUMS_DATES_PATH, { ...filters, groupBy: "trip" }); } export const listPotentialTripAssets = async ( @@ -55,9 +55,9 @@ export const listPotentialTripAssets = async ( endDate: string, city?: string | null, ): Promise => { - const params: Record = { startDate, endDate }; + const params: Record = { startDate, endDate, groupBy: "trip" }; if (city) params.city = city; - return API.get(LIST_POTENTIAL_TRIP_ASSETS_PATH, params).then((assets) => assets.map(cleanUpAsset)); + return API.get(LIST_POTENTIAL_ALBUMS_ASSETS_PATH, params).then((assets) => assets.map(cleanUpAsset)); } export const listAlbums = async (filters?: { sortBy?: string, sortOrder?: string }) => { diff --git a/src/helpers/potentialAlbumsAssets.helper.ts b/src/helpers/potentialAlbumsAssets.helper.ts new file mode 100644 index 0000000..5c0dc98 --- /dev/null +++ b/src/helpers/potentialAlbumsAssets.helper.ts @@ -0,0 +1,103 @@ +// Asset-detail fetchers for the Potential Albums right-panel grid: either +// a single day, or a date range (trip). Both share one endpoint +// (potential-albums-assets.ts) via a `groupBy` param — this file holds the +// two DB-querying implementations. +import { db } from "@/config/db"; +import { isFlipped } from "@/helpers/asset.helper"; +import { sql } from "drizzle-orm"; + +const cleanRows = (rows: any[]) => + rows.map((row: any) => ({ + ...row, + exifImageWidth: isFlipped(row.orientation || 0) ? row.exifImageHeight : row.exifImageWidth, + exifImageHeight: isFlipped(row.orientation || 0) ? row.exifImageWidth : row.exifImageHeight, + })); + +const SELECT_ORPHAN_DAY = (date: string, ownerId: string) => + sql.raw(` + SELECT + a."id", + a."ownerId", + a."type", + a."originalPath", + a."isFavorite", + a."duration", + a."originalFileName", + a."thumbhash", + a."deletedAt", + e."exifImageWidth", + e."exifImageHeight", + e."dateTimeOriginal", + e."orientation" + FROM + asset a + LEFT JOIN + album_asset aaa + ON a.id = aaa."assetId" + LEFT JOIN + asset_exif e + ON a.id = e."assetId" + WHERE + aaa."albumId" IS NULL + AND a."ownerId" = '${ownerId}' + AND e."dateTimeOriginal"::date = '${date}' + AND a."visibility" = 'timeline' + ORDER BY + e."dateTimeOriginal" DESC +`); + +export const fetchDayAssets = async (ownerId: string, date: string) => { + const { rows } = await db.execute(SELECT_ORPHAN_DAY(date, ownerId)); + return cleanRows(rows); +}; + +// Optional `city` narrows the result to assets whose EXIF city matches the +// trip's dominant city. Assets with NULL city are included alongside the +// match: they may be photos taken during the trip but without GPS metadata +// (screenshots, edited copies, older devices, etc.) and excluding them would +// lose real trip content. If `city` is not provided the date range alone +// determines membership. +const SELECT_ORPHAN_RANGE = (startDate: string, endDate: string, ownerId: string, city: string | null) => { + const cityClause = city + ? `AND (e."city" = '${city.replace(/'/g, "''")}' OR e."city" IS NULL)` + : ""; + return sql.raw(` + SELECT + a."id", + a."ownerId", + a."type", + a."originalPath", + a."isFavorite", + a."duration", + a."originalFileName", + a."thumbhash", + a."deletedAt", + e."exifImageWidth", + e."exifImageHeight", + e."dateTimeOriginal", + e."orientation", + e."city", + e."country" + FROM + asset a + LEFT JOIN + album_asset aaa + ON a.id = aaa."assetId" + LEFT JOIN + asset_exif e + ON a.id = e."assetId" + WHERE + aaa."albumId" IS NULL + AND a."ownerId" = '${ownerId}' + AND e."dateTimeOriginal"::date BETWEEN '${startDate}' AND '${endDate}' + AND a."visibility" = 'timeline' + ${cityClause} + ORDER BY + e."dateTimeOriginal" ASC +`); +}; + +export const fetchTripAssets = async (ownerId: string, startDate: string, endDate: string, city: string | null) => { + const { rows } = await db.execute(SELECT_ORPHAN_RANGE(startDate, endDate, ownerId, city)); + return cleanRows(rows); +}; diff --git a/src/helpers/potentialAlbumsGrouping.helper.ts b/src/helpers/potentialAlbumsGrouping.helper.ts new file mode 100644 index 0000000..d456110 --- /dev/null +++ b/src/helpers/potentialAlbumsGrouping.helper.ts @@ -0,0 +1,92 @@ +// Grouping strategies for the Potential Albums list panel: group orphan +// assets either by single day, or by multi-day "trip" (consecutive away +// days). Both share one endpoint (potential-albums-dates.ts) via a +// `groupBy` param — this file holds the two DB-querying implementations. +import { db } from "@/config/db"; +import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; +import { assets } from "@/schema/assets.schema"; +import { exif } from "@/schema/exif.schema"; +import { and, avg, count, eq, isNotNull, isNull, sql } from "drizzle-orm"; +import { + ClusterOptions, + DaySignal, + DEFAULT_CLUSTER_OPTIONS, + clusterTrips, + computeHome, +} from "./trips.helper"; + +export interface DayGroupRow { + date: string; + asset_count: number; +} + +export const fetchDayGroups = async (ownerId: string): Promise => { + const rows = await db.select({ + date: sql`DATE(${exif.dateTimeOriginal})`, + asset_count: count(assets.id), + }).from(assets) + .leftJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetId)) + .leftJoin(exif, eq(assets.id, exif.assetId)) + .where(and( + eq(assets.ownerId, ownerId), + eq(assets.visibility, "timeline"), + isNull(albumsAssetsAssets.albumId), + isNotNull(exif.dateTimeOriginal), + )) + .groupBy(sql`DATE(${exif.dateTimeOriginal})`) as DayGroupRow[]; + + return rows; +}; + +export interface TripGroupOptions extends Partial {} + +export const fetchTripGroups = async (ownerId: string, opts: TripGroupOptions = {}) => { + // Per-day aggregation: count, centroid, dominant city/country via PG MODE(). + const rows = (await db + .select({ + date: sql`TO_CHAR(DATE(${exif.dateTimeOriginal}), 'YYYY-MM-DD')`, + assetCount: count(assets.id), + centroidLat: avg(exif.latitude), + centroidLon: avg(exif.longitude), + cityMode: sql`MODE() WITHIN GROUP (ORDER BY ${exif.city})`, + countryMode: sql`MODE() WITHIN GROUP (ORDER BY ${exif.country})`, + }) + .from(assets) + .leftJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetId)) + .leftJoin(exif, eq(assets.id, exif.assetId)) + .where( + and( + eq(assets.ownerId, ownerId), + eq(assets.visibility, "timeline"), + isNull(albumsAssetsAssets.albumId), + isNotNull(exif.dateTimeOriginal) + ) + ) + .groupBy(sql`DATE(${exif.dateTimeOriginal})`)) as Array<{ + date: string; + assetCount: number | string; + centroidLat: string | null; + centroidLon: string | null; + cityMode: string | null; + countryMode: string | null; + }>; + + const days: DaySignal[] = rows.map((r) => ({ + date: r.date, + assetCount: Number(r.assetCount), + cityMode: r.cityMode, + countryMode: r.countryMode, + centroidLat: r.centroidLat == null ? null : Number(r.centroidLat), + centroidLon: r.centroidLon == null ? null : Number(r.centroidLon), + })); + + const home = computeHome(days); + const trips = clusterTrips(days, { + home, + homeRadiusKm: opts.homeRadiusKm ?? DEFAULT_CLUSTER_OPTIONS.homeRadiusKm, + minTripDays: opts.minTripDays ?? DEFAULT_CLUSTER_OPTIONS.minTripDays, + gapTolerance: opts.gapTolerance ?? DEFAULT_CLUSTER_OPTIONS.gapTolerance, + }); + + return { home, trips }; +}; diff --git a/src/pages/api/albums/potential-albums-assets.ts b/src/pages/api/albums/potential-albums-assets.ts index 10832cc..933a3b1 100644 --- a/src/pages/api/albums/potential-albums-assets.ts +++ b/src/pages/api/albums/potential-albums-assets.ts @@ -1,61 +1,44 @@ -import { db } from "@/config/db"; +// Fetches orphan assets for the Potential Albums right-panel grid, either +// for a single day (default) or a date range (`?groupBy=trip`) — see +// src/helpers/potentialAlbumsAssets.helper.ts for the two implementations. import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; -import { isFlipped } from "@/helpers/asset.helper"; -import { sql } from "drizzle-orm"; +import { fetchDayAssets, fetchTripAssets } from "@/helpers/potentialAlbumsAssets.helper"; import type { NextApiRequest, NextApiResponse } from "next"; -const SELECT_ORPHAN_PHOTOS = (date: string, ownerId: string) => - sql.raw(` - SELECT - a."id", - a."ownerId", - a."deviceId", - a."type", - a."originalPath", - a."isFavorite", - a."duration", - a."originalFileName", - - a."thumbhash", - a."deletedAt", - e."exifImageWidth", - e."exifImageHeight", - e."dateTimeOriginal", - e."orientation" - FROM - asset a - LEFT JOIN - album_asset aaa - ON a.id = aaa."assetId" - LEFT JOIN - asset_exif e - ON a.id = e."assetId" - WHERE - aaa."albumId" IS NULL - AND a."ownerId" = '${ownerId}' - AND e."dateTimeOriginal"::date = '${date}' - AND a."visibility" = 'timeline' - ORDER BY - e."dateTimeOriginal" DESC -`); - export default async function handler( req: NextApiRequest, res: NextApiResponse ) { try { - const currentUser = await getCurrentUser(req) - const { startDate } = req.query as { startDate: string }; - const { rows } = await db.execute(SELECT_ORPHAN_PHOTOS(startDate, currentUser.id)); + const currentUser = await getCurrentUser(req); + if (!currentUser) return res.status(401).json({ message: "Unauthorized" }); - const cleanedRows = rows.map((row: any) => { - return { - ...row, - exifImageWidth: isFlipped(row.orientation || 0) ? row.exifImageHeight : row.exifImageWidth, - exifImageHeight: isFlipped(row.orientation || 0) ? row.exifImageWidth : row.exifImageHeight, - }; - }); - return res.status(200).json(cleanedRows); + const { groupBy = "day", startDate, endDate, city } = req.query as { + groupBy?: string; + startDate?: string; + endDate?: string; + city?: string; + }; + + if (groupBy === "trip") { + if (!startDate || !endDate) { + return res.status(400).json({ error: "startDate and endDate are required (YYYY-MM-DD)" }); + } + // Interpolated into raw SQL below, so reject anything that isn't a + // plain date to keep this endpoint from becoming an injection vector. + const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + if (!DATE_RE.test(startDate) || !DATE_RE.test(endDate)) { + return res.status(400).json({ error: "Invalid date format, expected YYYY-MM-DD" }); + } + const cleaned = await fetchTripAssets(currentUser.id, startDate, endDate, city ?? null); + return res.status(200).json(cleaned); + } + + if (!startDate) { + return res.status(400).json({ error: "startDate is required (YYYY-MM-DD)" }); + } + const cleaned = await fetchDayAssets(currentUser.id, startDate); + return res.status(200).json(cleaned); } catch (error: any) { console.error(error); res.status(500).json({ diff --git a/src/pages/api/albums/potential-albums-dates.ts b/src/pages/api/albums/potential-albums-dates.ts index e9670aa..e612c76 100644 --- a/src/pages/api/albums/potential-albums-dates.ts +++ b/src/pages/api/albums/potential-albums-dates.ts @@ -1,37 +1,34 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import { db } from "@/config/db"; -import { IPotentialAlbumsDatesResponse } from "@/handlers/api/album.handler"; +// Groups orphan assets for the Potential Albums list panel, either by single +// day (default) or by trip (`?groupBy=trip`, consecutive away days) — see +// src/helpers/potentialAlbumsGrouping.helper.ts for the two implementations. import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; import { parseDate } from "@/helpers/date.helper"; -import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; -import { assets } from "@/schema/assets.schema"; -import { exif } from "@/schema/exif.schema"; -import { and, count, desc, eq, isNotNull, isNull, sql } from "drizzle-orm"; +import { fetchDayGroups, fetchTripGroups } from "@/helpers/potentialAlbumsGrouping.helper"; import type { NextApiRequest, NextApiResponse } from "next"; - export default async function handler( req: NextApiRequest, res: NextApiResponse ) { try { - const { sortBy = "date", sortOrder = "desc", minAssets = 1 } = req.query as any; - const currentUser = await getCurrentUser(req) - const rows = await db.select({ - date: sql`DATE(${exif.dateTimeOriginal})`, - asset_count: desc(count(assets.id)), - }).from(assets) - .leftJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetId)) - .leftJoin(exif, eq(assets.id, exif.assetId)) - .where(and ( - eq(assets.ownerId, currentUser.id), - eq(assets.visibility, "timeline"), - isNull(albumsAssetsAssets.albumId), - isNotNull(exif.dateTimeOriginal), + const currentUser = await getCurrentUser(req); + if (!currentUser) return res.status(401).json({ message: "Unauthorized" }); - )) - .groupBy(sql`DATE(${exif.dateTimeOriginal})`) as IPotentialAlbumsDatesResponse[]; - + const { groupBy = "day" } = req.query as { groupBy?: string }; + + if (groupBy === "trip") { + const { homeRadiusKm, minTripDays, gapTolerance } = req.query as Record; + const result = await fetchTripGroups(currentUser.id, { + homeRadiusKm: homeRadiusKm !== undefined ? Number(homeRadiusKm) : undefined, + minTripDays: minTripDays !== undefined ? Number(minTripDays) : undefined, + gapTolerance: gapTolerance !== undefined ? Number(gapTolerance) : undefined, + }); + return res.status(200).json(result); + } + + const { sortBy = "date", sortOrder = "desc", minAssets = 1 } = req.query as any; + const rows = await fetchDayGroups(currentUser.id); const filteredRows = rows.filter((row) => Number(row.asset_count) >= Number(minAssets)); if (sortBy === "date") { diff --git a/src/pages/api/albums/potential-trip-assets.ts b/src/pages/api/albums/potential-trip-assets.ts deleted file mode 100644 index c7bf238..0000000 --- a/src/pages/api/albums/potential-trip-assets.ts +++ /dev/null @@ -1,94 +0,0 @@ -// Fetch orphan assets that fall within a date range (for the Potential Trips view). -import { db } from "@/config/db"; -import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; -import { isFlipped } from "@/helpers/asset.helper"; -import { sql } from "drizzle-orm"; -import type { NextApiRequest, NextApiResponse } from "next"; - -// Optional `city` narrows the result to assets whose EXIF city matches the -// trip's dominant city. Assets with NULL city are included alongside the -// match: they may be photos taken during the trip but without GPS metadata -// (screenshots, edited copies, older devices, etc.) and excluding them would -// lose real trip content. If `city` is not provided the date range alone -// determines membership. -const SELECT_ORPHAN_RANGE = ( - startDate: string, - endDate: string, - ownerId: string, - city: string | null -) => { - const cityClause = city - ? `AND (e."city" = '${city.replace(/'/g, "''")}' OR e."city" IS NULL)` - : ""; - return sql.raw(` - SELECT - a."id", - a."ownerId", - a."type", - a."originalPath", - a."isFavorite", - a."duration", - a."originalFileName", - a."thumbhash", - a."deletedAt", - e."exifImageWidth", - e."exifImageHeight", - e."dateTimeOriginal", - e."orientation", - e."city", - e."country" - FROM - asset a - LEFT JOIN - album_asset aaa - ON a.id = aaa."assetId" - LEFT JOIN - asset_exif e - ON a.id = e."assetId" - WHERE - aaa."albumId" IS NULL - AND a."ownerId" = '${ownerId}' - AND e."dateTimeOriginal"::date BETWEEN '${startDate}' AND '${endDate}' - AND a."visibility" = 'timeline' - ${cityClause} - ORDER BY - e."dateTimeOriginal" ASC -`); -}; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - try { - const currentUser = await getCurrentUser(req); - if (!currentUser) return res.status(401).json({ message: "Unauthorized" }); - - const { startDate, endDate, city } = req.query as { - startDate?: string; - endDate?: string; - city?: string; - }; - if (!startDate || !endDate) { - return res.status(400).json({ error: "startDate and endDate are required (YYYY-MM-DD)" }); - } - // Basic format sanity (the strings are interpolated into the SQL literal, so - // reject anything that isn't YYYY-MM-DD to keep this endpoint from becoming - // an injection vector — same shape check the single-date endpoint should have.) - const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; - if (!DATE_RE.test(startDate) || !DATE_RE.test(endDate)) { - return res.status(400).json({ error: "Invalid date format, expected YYYY-MM-DD" }); - } - - const { rows } = await db.execute( - SELECT_ORPHAN_RANGE(startDate, endDate, currentUser.id, city ?? null) - ); - - const cleaned = rows.map((row: any) => ({ - ...row, - exifImageWidth: isFlipped(row.orientation || 0) ? row.exifImageHeight : row.exifImageWidth, - exifImageHeight: isFlipped(row.orientation || 0) ? row.exifImageWidth : row.exifImageHeight, - })); - return res.status(200).json(cleaned); - } catch (error: any) { - console.error(error); - return res.status(500).json({ error: error?.message }); - } -} diff --git a/src/pages/api/albums/potential-trips.ts b/src/pages/api/albums/potential-trips.ts deleted file mode 100644 index ce59041..0000000 --- a/src/pages/api/albums/potential-trips.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Potential trips — multi-day stretches of photos away from home. -// Companion to /api/albums/potential-albums-dates (which groups by single day). -import { db } from "@/config/db"; -import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; -import { - DaySignal, - DEFAULT_CLUSTER_OPTIONS, - clusterTrips, - computeHome, -} from "@/helpers/trips.helper"; -import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; -import { assets } from "@/schema/assets.schema"; -import { exif } from "@/schema/exif.schema"; -import { and, avg, count, eq, isNotNull, isNull, sql } from "drizzle-orm"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - try { - const { - homeRadiusKm = DEFAULT_CLUSTER_OPTIONS.homeRadiusKm, - minTripDays = DEFAULT_CLUSTER_OPTIONS.minTripDays, - gapTolerance = DEFAULT_CLUSTER_OPTIONS.gapTolerance, - } = req.query as Record; - - const currentUser = await getCurrentUser(req); - if (!currentUser) return res.status(401).json({ message: "Unauthorized" }); - - // Per-day aggregation: count, centroid, dominant city/country via PG MODE(). - const rows = (await db - .select({ - date: sql`TO_CHAR(DATE(${exif.dateTimeOriginal}), 'YYYY-MM-DD')`, - assetCount: count(assets.id), - centroidLat: avg(exif.latitude), - centroidLon: avg(exif.longitude), - cityMode: sql`MODE() WITHIN GROUP (ORDER BY ${exif.city})`, - countryMode: sql`MODE() WITHIN GROUP (ORDER BY ${exif.country})`, - }) - .from(assets) - .leftJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetId)) - .leftJoin(exif, eq(assets.id, exif.assetId)) - .where( - and( - eq(assets.ownerId, currentUser.id), - eq(assets.visibility, "timeline"), - isNull(albumsAssetsAssets.albumId), - isNotNull(exif.dateTimeOriginal) - ) - ) - .groupBy(sql`DATE(${exif.dateTimeOriginal})`)) as Array<{ - date: string; - assetCount: number | string; - centroidLat: string | null; - centroidLon: string | null; - cityMode: string | null; - countryMode: string | null; - }>; - - const days: DaySignal[] = rows.map((r) => ({ - date: r.date, - assetCount: Number(r.assetCount), - cityMode: r.cityMode, - countryMode: r.countryMode, - centroidLat: r.centroidLat == null ? null : Number(r.centroidLat), - centroidLon: r.centroidLon == null ? null : Number(r.centroidLon), - })); - - const home = computeHome(days); - const trips = clusterTrips(days, { - home, - homeRadiusKm: Number(homeRadiusKm), - minTripDays: Number(minTripDays), - gapTolerance: Number(gapTolerance), - }); - - return res.status(200).json({ home, trips }); - } catch (error: any) { - console.error(error); - return res.status(500).json({ error: error?.message }); - } -}